image-2.png

Análisis y Predicción del Riesgo de Siniestro en Seguros de Automóviles

Un Estudio con Datos de Porto Seguro

  • Marc Román Porras
  • Máster Universitario en Ciencia de Datos
  • Universitat Oberta de Catalunya
  • 🌐 Acceso al Github del proyecto

    Tabla de Contenidos:¶

    • 1. Carga y preparación inicial del dataset
    • 2. Análisis exploratorio de datos (EDA)
    • 3. Preparación de datos
    • 4. Modelización
    • 5. Entrenamiento y Evaluación
    • 6. Explicabilidad
    • 7. Producción (basis)

    1. Carga y Preparación Inicial del Dataset

    Importación de librerías

    In [1]:
    # Paquetería necesaria:
    
    import pandas as pd
    import numpy as np
    import matplotlib.pyplot as plt
    import plotly.subplots as sp
    import plotly.graph_objects as go
    import seaborn as sns
    import plotly.express as px
    import math
    import pickle
    import optuna
    import optuna.visualization.matplotlib as opt_viz
    import subprocess
    import time
    from IPython.display import display, HTML
    
    # Apertura del servidor guardado de Optuna:
    import webbrowser
    
    # Modelización:
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.inspection import permutation_importance,PartialDependenceDisplay
    from sklearn.preprocessing import StandardScaler, QuantileTransformer,OneHotEncoder
    from sklearn.model_selection import train_test_split,StratifiedKFold
    from sklearn.impute import SimpleImputer
    from sklearn.metrics import (
        roc_auc_score,
        average_precision_score,
        recall_score,
        f1_score,
        precision_score,
        accuracy_score,
        brier_score_loss,
        confusion_matrix,
        ConfusionMatrixDisplay,
        auc,
        precision_recall_curve
    )
    from sklearn.calibration import CalibratedClassifierCV, calibration_curve
    from xgboost import XGBClassifier
    import xgboost as xgb
    from lightgbm import LGBMClassifier
    from sklearn.linear_model import LogisticRegression
    from pytorch_tabnet.tab_model import TabNetClassifier
    
    from statsmodels.stats.outliers_influence import variance_inflation_factor
    import scipy.stats as stats
    from scipy.stats import ks_2samp
    from skimage.filters import threshold_otsu
    
    # XAI
    import shap
    import lime
    import lime.lime_tabular
    import os
    
    # Paleta de colores basada en azules para unificar la visualización de datos:
    custom_palette = ["#003f5c", "#2f4b7c", "#665191", "#a05195", "#d45087", "#f95d6a"]
    sns.set_palette(custom_palette) 
    
    c:\Program Files\Python311\Lib\site-packages\tqdm\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
      from .autonotebook import tqdm as notebook_tqdm
    

    Carga de datos y estructura inicial

    In [2]:
    # Carga de los conjuntos de datos de train y test, respectivamente desde la fuente de datos orígen (local):
    train = pd.read_csv("./train.csv")
    test = pd.read_csv("./test.csv")
    
    In [3]:
    # Instancia de visualización raw tras la carga:
    print(f"Dimensiones del dataset de entrenamiento: {train.shape}")
    print(f"Dimensiones del dataset de test: {test.shape}")
    
    # Muestro las primeras filas
    train.head()
    
    Dimensiones del dataset de entrenamiento: (595212, 59)
    Dimensiones del dataset de test: (892816, 58)
    
    Out[3]:
    id target ps_ind_01 ps_ind_02_cat ps_ind_03 ps_ind_04_cat ps_ind_05_cat ps_ind_06_bin ps_ind_07_bin ps_ind_08_bin ... ps_calc_11 ps_calc_12 ps_calc_13 ps_calc_14 ps_calc_15_bin ps_calc_16_bin ps_calc_17_bin ps_calc_18_bin ps_calc_19_bin ps_calc_20_bin
    0 7 0 2 2 5 1 0 0 1 0 ... 9 1 5 8 0 1 1 0 0 1
    1 9 0 1 1 7 0 0 0 0 1 ... 3 1 1 9 0 1 1 0 1 0
    2 13 0 5 4 9 1 0 0 0 1 ... 4 2 7 7 0 1 1 0 1 0
    3 16 0 0 1 2 0 0 1 0 0 ... 2 2 4 9 0 0 0 0 0 0
    4 17 0 0 2 0 1 0 1 0 0 ... 3 1 1 3 0 0 0 1 1 0

    5 rows × 59 columns

    In [4]:
    # Revisión de datos inicial:
    train.info()
    
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 595212 entries, 0 to 595211
    Data columns (total 59 columns):
     #   Column          Non-Null Count   Dtype  
    ---  ------          --------------   -----  
     0   id              595212 non-null  int64  
     1   target          595212 non-null  int64  
     2   ps_ind_01       595212 non-null  int64  
     3   ps_ind_02_cat   595212 non-null  int64  
     4   ps_ind_03       595212 non-null  int64  
     5   ps_ind_04_cat   595212 non-null  int64  
     6   ps_ind_05_cat   595212 non-null  int64  
     7   ps_ind_06_bin   595212 non-null  int64  
     8   ps_ind_07_bin   595212 non-null  int64  
     9   ps_ind_08_bin   595212 non-null  int64  
     10  ps_ind_09_bin   595212 non-null  int64  
     11  ps_ind_10_bin   595212 non-null  int64  
     12  ps_ind_11_bin   595212 non-null  int64  
     13  ps_ind_12_bin   595212 non-null  int64  
     14  ps_ind_13_bin   595212 non-null  int64  
     15  ps_ind_14       595212 non-null  int64  
     16  ps_ind_15       595212 non-null  int64  
     17  ps_ind_16_bin   595212 non-null  int64  
     18  ps_ind_17_bin   595212 non-null  int64  
     19  ps_ind_18_bin   595212 non-null  int64  
     20  ps_reg_01       595212 non-null  float64
     21  ps_reg_02       595212 non-null  float64
     22  ps_reg_03       595212 non-null  float64
     23  ps_car_01_cat   595212 non-null  int64  
     24  ps_car_02_cat   595212 non-null  int64  
     25  ps_car_03_cat   595212 non-null  int64  
     26  ps_car_04_cat   595212 non-null  int64  
     27  ps_car_05_cat   595212 non-null  int64  
     28  ps_car_06_cat   595212 non-null  int64  
     29  ps_car_07_cat   595212 non-null  int64  
     30  ps_car_08_cat   595212 non-null  int64  
     31  ps_car_09_cat   595212 non-null  int64  
     32  ps_car_10_cat   595212 non-null  int64  
     33  ps_car_11_cat   595212 non-null  int64  
     34  ps_car_11       595212 non-null  int64  
     35  ps_car_12       595212 non-null  float64
     36  ps_car_13       595212 non-null  float64
     37  ps_car_14       595212 non-null  float64
     38  ps_car_15       595212 non-null  float64
     39  ps_calc_01      595212 non-null  float64
     40  ps_calc_02      595212 non-null  float64
     41  ps_calc_03      595212 non-null  float64
     42  ps_calc_04      595212 non-null  int64  
     43  ps_calc_05      595212 non-null  int64  
     44  ps_calc_06      595212 non-null  int64  
     45  ps_calc_07      595212 non-null  int64  
     46  ps_calc_08      595212 non-null  int64  
     47  ps_calc_09      595212 non-null  int64  
     48  ps_calc_10      595212 non-null  int64  
     49  ps_calc_11      595212 non-null  int64  
     50  ps_calc_12      595212 non-null  int64  
     51  ps_calc_13      595212 non-null  int64  
     52  ps_calc_14      595212 non-null  int64  
     53  ps_calc_15_bin  595212 non-null  int64  
     54  ps_calc_16_bin  595212 non-null  int64  
     55  ps_calc_17_bin  595212 non-null  int64  
     56  ps_calc_18_bin  595212 non-null  int64  
     57  ps_calc_19_bin  595212 non-null  int64  
     58  ps_calc_20_bin  595212 non-null  int64  
    dtypes: float64(10), int64(49)
    memory usage: 267.9 MB
    

    El dataset, según podemos ver con la instancia anterior, presenta únicamente dos tipos de datos a nivel estructural: int64 y float64. A pesar de esta simplicidad aparente, la descripción proporcionada por el propio conjunto de datos (fuente: Kaggle) indica que las variables pueden clasificarse en tres categorías, deducibles por sufijos:

    • _bin: Variables binarias
    • _cat: Variables categóricas multinivel
    • Sin sufijo: Se interpretan como cuantitativas (continuas o discretas)

    Estas tres categorías, pueden ser inducidas a simple vista a partir de los sujifos del nombre de cada variable (ind, reg, calc, etc.), que dan una pista contextual de su tipología. No obstante, no se dispone de una descripción funcional clara de cada campo más allá que su nombre y lo que se extraerá a partir del análisis descriptivo. La naturaleza del dataset es por tanto ofuscada; no podemos tener consciencia situacional del contenido funcional de las variables, más allá de nuestro objetivo y de las especificidades que podamos inducir de su análisis estadístico/descriptivo que se llevará a cabo en los próximos apartados. A efectos técnicos, la ofuscación de un conjunto de datos de una aseguradora y/o de cualquier entidad financiera, permite presisamente poner a disposición su uso para casos de uso abiertos, como el presente en este trabajo, sin tener problemas de integridad en lo relativo a la privacidad y seguridad del dato, al contener este elementos privados del contrato de cada cliente con la entidad aseguradora, o elementos idiosincráticos tales como su salario, ingresos otros, etc... En definitiva, tenemos un conjunto de datos que, si bien tenemos el contexto del sector asegurador, este no se requiere de manera exhaustiva pues el enfoque osfuscado, limita las consideraciones puras del negocio que puedam derivarse de las variables, ya que no conocemos a qué refieren.

    Una necesidad primeriza es el establecimiento/designación de la tipología de cada variable que tenemos, de cara a capacitarnos una tipología de análisis u otra. Por ello, se propone un enfoque híbrido para designar qué tipología de variables tenemos y cómo deben ser interpretadas, más allá del nombre. Se aplicará una clasificación auxiliar según el número de niveles únicos de cada variable. Esta clasificación, se apoyará en un umbral (threshold) de 20 valores únicos, permitiendo la siguiente interpretación:

    • Variables con ≤ 20 niveles únicos: consideradas categóricas
    • Variables con > 20 niveles únicos: consideradas cuantitativas continuas

    Aunque este umbral pueda parecer bajo para algunas variables continuas, se justifica empíricamente por la naturaleza del conjunto de datos, y permite una distinción pragmática y operativa en el análisis posterior.

    In [5]:
    # Resumen de la totalidad de las variables:
    
    def generar_resumen(df, threshold_cual, solo_cuantitativas=False):
        """
        Genera una tabla resumen de las variables en un DataFrame,
        clasificándolas por tipo y ordenándolas en consecuencia.
        
        Parámetros:
        - df: DataFrame de entrada.
        - threshold_cual: Umbral para considerar una variable como cualitativa o continua.
        - solo_cuantitativas: Si es True, filtra solo las variables cuantitativas (continua o cont/cual).
        Retorno:
        - Un DataFrame con la información formateada.
        """
        # Elimino la columna 'id' si está presente
        temp_df = df.drop(columns=["id"], errors="ignore")
    
        summary = pd.DataFrame(temp_df.dtypes, columns=["Data Type"])
        summary["num_na"] = (temp_df == -1).sum().values
        summary["num_unique"] = temp_df.nunique().values
        summary["Category"] = None
        summary["Variable Type"] = None 
    
        for col in temp_df.columns:
            num_uniques = summary.loc[col, "num_unique"]
            
            if "bin" in col or col == "target":
                summary.loc[col, "Category"] = "Binaria"
            elif "cat" in col:
                summary.loc[col, "Category"] = "Cualitativa"
            elif pd.api.types.is_numeric_dtype(temp_df[col]):  
                if np.issubdtype(temp_df[col].dtype, np.floating): 
                    summary.loc[col, "Category"] = "Continua"
                elif np.issubdtype(temp_df[col].dtype, np.integer): 
                    summary.loc[col, "Category"] = "Cont/Cual"
            else:
                summary.loc[col, "Category"] = "Unknown" 
    
            # Asigno Variable Type basado en num_unique:
            if num_uniques <= threshold_cual:
                summary.loc[col, "Variable Type"] = "Cualitativa"
            else:
                summary.loc[col, "Variable Type"] = "Continua"
    
        # Filtrado si solo se quieren cuantitativas para futura viz:
        if solo_cuantitativas:
            summary = summary[summary["Variable Type"].isin(["Continua"])]
    
        category_order = {"Binaria": 1, "Cualitativa": 2, "Continua": 3, "Cont/Cual": 4}
        summary["Category Order"] = summary["Category"].map(category_order)
        summary = summary.sort_values(by="Category Order")
        summary = summary.drop(columns=["Category Order"])
    
        cmap = sns.light_palette("skyblue", as_cmap=True)
        
        return summary.style.background_gradient(cmap=cmap)
    
    In [6]:
    # Ejecuto la tabla resumen con el dataset original y un threshold para considerar una variable como "contínua", 
    # cuando supera los 20 valores distintos sobre el conjunto total::
    threshold_cual=20
    generar_resumen(train,threshold_cual)
    
    Out[6]:
      Data Type num_na num_unique Category Variable Type
    target int64 0 2 Binaria Cualitativa
    ps_calc_18_bin int64 0 2 Binaria Cualitativa
    ps_calc_17_bin int64 0 2 Binaria Cualitativa
    ps_calc_16_bin int64 0 2 Binaria Cualitativa
    ps_calc_15_bin int64 0 2 Binaria Cualitativa
    ps_calc_19_bin int64 0 2 Binaria Cualitativa
    ps_ind_18_bin int64 0 2 Binaria Cualitativa
    ps_ind_17_bin int64 0 2 Binaria Cualitativa
    ps_ind_16_bin int64 0 2 Binaria Cualitativa
    ps_ind_13_bin int64 0 2 Binaria Cualitativa
    ps_ind_12_bin int64 0 2 Binaria Cualitativa
    ps_calc_20_bin int64 0 2 Binaria Cualitativa
    ps_ind_10_bin int64 0 2 Binaria Cualitativa
    ps_ind_09_bin int64 0 2 Binaria Cualitativa
    ps_ind_08_bin int64 0 2 Binaria Cualitativa
    ps_ind_07_bin int64 0 2 Binaria Cualitativa
    ps_ind_06_bin int64 0 2 Binaria Cualitativa
    ps_ind_11_bin int64 0 2 Binaria Cualitativa
    ps_ind_02_cat int64 216 5 Cualitativa Cualitativa
    ps_ind_04_cat int64 83 3 Cualitativa Cualitativa
    ps_car_11_cat int64 0 104 Cualitativa Continua
    ps_car_10_cat int64 0 3 Cualitativa Cualitativa
    ps_car_09_cat int64 569 6 Cualitativa Cualitativa
    ps_car_08_cat int64 0 2 Cualitativa Cualitativa
    ps_ind_05_cat int64 5809 8 Cualitativa Cualitativa
    ps_car_06_cat int64 0 18 Cualitativa Cualitativa
    ps_car_07_cat int64 11489 3 Cualitativa Cualitativa
    ps_car_04_cat int64 0 10 Cualitativa Cualitativa
    ps_car_03_cat int64 411231 3 Cualitativa Cualitativa
    ps_car_02_cat int64 5 3 Cualitativa Cualitativa
    ps_car_01_cat int64 107 13 Cualitativa Cualitativa
    ps_car_05_cat int64 266551 3 Cualitativa Cualitativa
    ps_calc_03 float64 0 10 Continua Cualitativa
    ps_calc_02 float64 0 10 Continua Cualitativa
    ps_calc_01 float64 0 10 Continua Cualitativa
    ps_car_15 float64 0 15 Continua Cualitativa
    ps_car_12 float64 1 184 Continua Continua
    ps_car_13 float64 0 70482 Continua Continua
    ps_reg_01 float64 0 10 Continua Cualitativa
    ps_reg_02 float64 0 19 Continua Cualitativa
    ps_reg_03 float64 107772 5013 Continua Continua
    ps_car_14 float64 42620 850 Continua Continua
    ps_ind_01 int64 0 8 Cont/Cual Cualitativa
    ps_ind_14 int64 0 5 Cont/Cual Cualitativa
    ps_ind_03 int64 0 12 Cont/Cual Cualitativa
    ps_ind_15 int64 0 14 Cont/Cual Cualitativa
    ps_calc_14 int64 0 24 Cont/Cual Continua
    ps_calc_13 int64 0 14 Cont/Cual Cualitativa
    ps_calc_12 int64 0 11 Cont/Cual Cualitativa
    ps_calc_10 int64 0 26 Cont/Cual Continua
    ps_calc_09 int64 0 8 Cont/Cual Cualitativa
    ps_calc_08 int64 0 11 Cont/Cual Cualitativa
    ps_calc_07 int64 0 10 Cont/Cual Cualitativa
    ps_calc_06 int64 0 11 Cont/Cual Cualitativa
    ps_calc_04 int64 0 6 Cont/Cual Cualitativa
    ps_car_11 int64 5 5 Cont/Cual Cualitativa
    ps_calc_11 int64 0 20 Cont/Cual Cualitativa
    ps_calc_05 int64 0 7 Cont/Cual Cualitativa
    In [7]:
    # Visualizo solo continuas asumidas:
    generar_resumen(train,threshold_cual, True)
    
    Out[7]:
      Data Type num_na num_unique Category Variable Type
    ps_car_11_cat int64 0 104 Cualitativa Continua
    ps_reg_03 float64 107772 5013 Continua Continua
    ps_car_12 float64 1 184 Continua Continua
    ps_car_13 float64 0 70482 Continua Continua
    ps_car_14 float64 42620 850 Continua Continua
    ps_calc_10 int64 0 26 Cont/Cual Continua
    ps_calc_14 int64 0 24 Cont/Cual Continua

    Del resultado, vemos 2 columnas relativas a la tipología: Category, referente a la categoría expresada por el nombre de la variable, y Variable Type, que es la tipología inducida por el criterio de 20 niveles que se ha decidido imponer. Más en detalle, del resultado PS_CALC_10 y PS_CALC_14, podrían ser las más problemáticas, pero assumimos un potencial error en la consideración del threshold.

    In [8]:
    # Segmentación/separación de variables en función del threshold anteriormente definido:
    
    def classify_variables(df, target, threshold_cual=20):
        """
        Clasifica las variables en binarias, categóricas o continuas, sin modificar el dataset.
        
        Parámetros:
        - df: DataFrame con los datos.
        - target: Nombre de la variable objetivo (se excluye de la clasificación).
        - threshold_cual: Número máximo de valores únicos para considerar una variable como categórica.
        
        Retorno:
        - binary_vars: Lista de variables binarias.
        - categorical_vars: Lista de variables categóricas.
        - continuous_vars: Lista de variables continuas.
        """
        # Identificar la columna ID (suponiendo que es la primera columna)
        id_column = df.columns[0]
    
        binary_vars = []
        categorical_vars = []
        continuous_vars = []
    
        for col in df.columns:
            if col in [target, id_column]:  # Excluir target e ID
                continue
            unique_values = df[col].nunique()
    
            if unique_values == 2:
                binary_vars.append(col)
            elif unique_values <= threshold_cual:
                categorical_vars.append(col)
            else:
                continuous_vars.append(col)
    
        return binary_vars, categorical_vars, continuous_vars
    
    # Aplicar la función al dataset train
    target='target'
    binary_vars, categorical_vars, continuous_vars = classify_variables(train, target)
    
    # Muestra del resultado
    print(f"Variables binarias: {binary_vars}\n")
    print(f"Variables categóricas: {categorical_vars}\n")
    print(f"Variables continuas: {continuous_vars}\n")
    
    Variables binarias: ['ps_ind_06_bin', 'ps_ind_07_bin', 'ps_ind_08_bin', 'ps_ind_09_bin', 'ps_ind_10_bin', 'ps_ind_11_bin', 'ps_ind_12_bin', 'ps_ind_13_bin', 'ps_ind_16_bin', 'ps_ind_17_bin', 'ps_ind_18_bin', 'ps_car_08_cat', 'ps_calc_15_bin', 'ps_calc_16_bin', 'ps_calc_17_bin', 'ps_calc_18_bin', 'ps_calc_19_bin', 'ps_calc_20_bin']
    
    Variables categóricas: ['ps_ind_01', 'ps_ind_02_cat', 'ps_ind_03', 'ps_ind_04_cat', 'ps_ind_05_cat', 'ps_ind_14', 'ps_ind_15', 'ps_reg_01', 'ps_reg_02', 'ps_car_01_cat', 'ps_car_02_cat', 'ps_car_03_cat', 'ps_car_04_cat', 'ps_car_05_cat', 'ps_car_06_cat', 'ps_car_07_cat', 'ps_car_09_cat', 'ps_car_10_cat', 'ps_car_11', 'ps_car_15', 'ps_calc_01', 'ps_calc_02', 'ps_calc_03', 'ps_calc_04', 'ps_calc_05', 'ps_calc_06', 'ps_calc_07', 'ps_calc_08', 'ps_calc_09', 'ps_calc_11', 'ps_calc_12', 'ps_calc_13']
    
    Variables continuas: ['ps_reg_03', 'ps_car_11_cat', 'ps_car_12', 'ps_car_13', 'ps_car_14', 'ps_calc_10', 'ps_calc_14']
    
    
    In [9]:
    # Vsualización de la tipología a alto nivel en nuestro dataset:
    df_types = pd.DataFrame({
        "Variable": binary_vars + categorical_vars + continuous_vars,
        "Tipo": ["Binaria"] * len(binary_vars) + ["Categorica"] * len(categorical_vars) + ["Continua"] * len(continuous_vars)
    })
    
    sns.set(style="whitegrid", palette="Blues")
    
    plt.figure(figsize=(20, 5))
    ax = sns.countplot(data=df_types, y="Tipo", order=["Binaria", "Categorica", "Continua"])
    plt.title("Distribución de Tipos de Variables en el Dataset")
    plt.xlabel("Cantidad de Variables")
    plt.ylabel("Tipo de Variable")
    plt.show()
    
    No description has been provided for this image

    2. Análisis exploratorio de datos (EDA)

    Visualización del target

    In [10]:
    # Visualización del target:
    
    target_counts = train["target"].value_counts().reset_index()
    target_counts.columns = ["Clase", "Frecuencia"]
    target_counts["Porcentaje"] = (target_counts["Frecuencia"] / target_counts["Frecuencia"].sum()) * 100
    color_scale = ["#00274D", "#004488", "#0066CC", "#3399FF", "#66B2FF"]
    
    fig = px.bar(target_counts, x="Clase", y="Frecuencia", text="Porcentaje",
                 title="Distribución de la Variable Objetivo",
                 labels={"Clase": "Clase", "Frecuencia": "Frecuencia"},
                 color="Frecuencia", color_continuous_scale=color_scale)
    
    fig.update_traces(texttemplate='%{text:.2f}%', textposition='outside')
    fig.update_layout(xaxis=dict(tickmode='array', tickvals=[0, 1], ticktext=['Clase 0', 'Clase 1']),
                      coloraxis_showscale=False,
                      template="plotly_white")
    fig.show()
    
    No description has been provided for this image

    Del plot anterior concluimos acerca de:

    • La variable objetivo está altamente desbalanceada. Solo el 3.64% de las observaciones pertenecen a la Clase 1 (evento de riesgo de siniestralidad), mientras que el 96.36% están en la Clase 0 (no evento).

    • Este desbalance extremo puede afectar negativamente el rendimiento de los modelos de clasificación, especialmente en métricas como accuracy, si se evalúa un modelo de manera tan generalista.

      Por ello, será necesario aplicar técnicas para manejar el desbalance (p. ej., ajustar class_weight, modificar umbrales de decisión, o usar métricas como PR-AUC y F1-score para evaluación).

    Análisis bivariante de variables vs target.

    In [11]:
    # Variables Categóricas vs Target en Grid:
    
    num_vars = len(categorical_vars)
    num_cols = 3
    num_rows = (num_vars // num_cols) + (num_vars % num_cols > 0)
    fig_cat_target = sp.make_subplots(rows=num_rows, cols=num_cols, subplot_titles=categorical_vars)
    row, col = 1, 1
    
    for var in categorical_vars:
        df_target = train.groupby([var, "target"]).size().reset_index(name="count")
        trace = go.Bar(x=df_target[var], y=df_target["count"], marker_color=df_target["target"].map({0: "royalblue", 1: "darkblue"}), name=var)
        fig_cat_target.add_trace(trace, row=row, col=col)
    
        col += 1
        if col > num_cols:
            col = 1
            row += 1
    
    fig_cat_target.update_layout(title="Distribución de Variables Categóricas por Target", showlegend=False, height=300 * num_rows)
    fig_cat_target.show()
    
    No description has been provided for this image

    Análisis Bivariante: Variables Categóricas vs Target

    El análisis bivariante permite observar la relación entre los niveles de cada variable cualitatuiva y la frecuencia del evento. En nuestro caso de uso específico, se analizan visualmente las proporciones relativas de siniestros dentro de cada categoría, lo que aporta una primera capa de entendimiento sobre el posible valor predictivo de las variables.

    Variables con mayor poder discriminativo visual

    • ps_car_05_cat: Se identifican diferencias claras en la proporción de Clase 1 entre los niveles -1 (miss), 0 y 1, sugiriendo que la codificación o agrupamiento puede capturar información relevante.
    • ps_car_11: Se observa un patrón ascendente de proporción de eventos conforme aumenta el nivel, lo que podría interpretarse como una relación ordinal con el riesgo de siniestralidad.

    Variables dominadas por un solo nivel

    • ps_ind_14, ps_car_06_cat, ps_car_10_cat: Aunque estas variables están fuertemente sesgadas hacia una única categoría dominante, algunas muestran leves diferencias en proporciones de eventos. No deben descartarse sin una validación empírica posterior, especialmente si la clase dominante presenta un comportamiento diferencial respecto al target.

    Variables binarias o con clases desbalanceadas

    • ps_car_02_cat, ps_car_07_cat, ps_ind_02_cat: En estos casos, un nivel concentra la mayoría de los registros. A pesar de ello, la diferencia en la tasa de eventos entre niveles puede aportar valor, especialmente en modelos con tratamiento adecuado del desbalance.

    Variables con muchos niveles o patrón difuso

    • ps_ind_15, ps_car_01_cat, ps_car_15, y varias de las ps_calc_* presentan patrones menos evidentes. Aun así, pueden contener relaciones útiles que no se aprecian a simple vista. Su análisis puede beneficiarse de binning supervisado o técnicas de reducción de cardinalidad.
    • Estas son las variables que, han entrado en los extremos de nuestro criterio/filtro inicial experto para considerar una variable cualitativa o cuantitativa.

    Consideración del análisis visual

    Este análisis exploratorio bivariante aporta una primera sensibilidad de carácter visual, que permite identificar variables con diferencias significativas en la distribución del target según sus niveles. Sin embargo, es importante destacar que la fase de selección de variables se basará en un fundamento complemente empírico, donde se evaluará cuantitativamente el potencial predictivo de cada variable mediante técnicas estadísticas como el Information Value (IV) o pruebas de independencia.

    De este modo, se filtrarán aquellas categorías que, aunque visualmente prometedoras, no aporten valor real al modelo.

    Por esa misma razón, no se exploran/explotan técnicas de representación visual más avanzada, pues será empíricamente como se identifique la capacidad de cada variable para explicar el evento de siniestro, que será usada para seleccionar las que mejor lo hagan, filtrando un subconjunto del total más acotado y más explicable/manipulable.

    In [12]:
    # Histogramas de Variables Continuas por Target en Grid:
    
    num_vars_cont = len(continuous_vars)
    num_rows_cont = (num_vars_cont // num_cols) + (num_vars_cont % num_cols > 0)
    fig_cont_hist = sp.make_subplots(rows=num_rows_cont, cols=num_cols, subplot_titles=continuous_vars)
    
    row, col = 1, 1
    for var in continuous_vars:
        trace0 = go.Histogram(x=train[train["target"] == 0][var], name=f"{var} (Target=0)", opacity=0.6, marker_color="royalblue")
        trace1 = go.Histogram(x=train[train["target"] == 1][var], name=f"{var} (Target=1)", opacity=0.6, marker_color="darkblue")
    
        fig_cont_hist.add_trace(trace0, row=row, col=col)
        fig_cont_hist.add_trace(trace1, row=row, col=col)
    
        col += 1
        if col > num_cols:
            col = 1
            row += 1
    
    fig_cont_hist.update_layout(title="Distribución de Variables Continuas por Target", showlegend=False, height=300 * num_rows_cont)
    fig_cont_hist.show()
    
    No description has been provided for this image

    Análisis Bivariante: Variables Cuantitativas vs Target

    Este análisis explora la relación entre las variables continuas y la variable objetivo (evento de siniestralidad). A través de histogramas de densidad superpuestos, se busca detectar diferencias en la forma, tendencia central o dispersión entre ambas clases, lo cual puede sugerir potencial predictivo.

    Variables con diferencias visuales claras entre clases

    • ps_reg_03: La Clase 1 tiende a concentrarse en valores más bajos de la variable. Se evidencia una ligera asimetría que podría aprovecharse en modelos no lineales o mediante binning supervisado.
    • ps_car_13: Se observa un desplazamiento hacia la izquierda en la distribución de la Clase 1, con un mayor número de eventos en rangos bajos. Este patrón sugiere valor informativo en la variable.
    • ps_calc_10 y ps_calc_14: Presentan distribuciones con forma campaniforme (gaussianas truncadas), pero en ambos casos la Clase 1 muestra un ligero desplazamiento hacia valores inferiores. Aunque sutil, puede ser relevante en modelos sensibles a la distribución.

    Variables con patrones menos claros o dominadas por picos

    • ps_car_11_cat: Exhibe una gran cantidad de valores discretos con una frecuencia extrema en un único punto (100). La Clase 1 parece estar algo más distribuida, pero la interpretación requiere codificación o binning.
    • ps_car_12 y ps_car_14: Están dominadas por valores puntuales o discretos con alta frecuencia. Aunque las diferencias no son visualmente pronunciadas, podrían contener valor tras transformación.
    • ps_car_14: Ligeros desplazamientos de la densidad de la Clase 1 hacia valores inferiores. Este comportamiento puede aprovecharse mediante normalización o categorización.

    Consideraciones sobre la forma de la distribución

    • Muchas variables, como ps_calc_10 y ps_calc_14, presentan asimetría positiva (cola a la derecha), lo que sugiere la necesidad de técnicas como log-transform o robust scaling.
    • En general, las diferencias entre clases no son tan evidentes como en algunas categóricas, pero sí existen pequeños desplazamientos que, combinados, pueden aportar valor al modelo final.

    Consideración final

    El análisis bivariante de variables cuantitativas permite detectar ligeros desplazamientos o cambios en la forma de la distribución del target. Aunque estas diferencias son, en la mayoría de los casos, sutiles a nivel visual, ofrecen una primera aproximación sobre la capacidad de segmentación del riesgo.

    Del mismo modo que para las variables cualitativas, a pesar de este enfoque exploratorio, se destaca que la evaluación definitiva del valor predictivo se realizará de forma empírica mediante técnicas de selección de variables, validación cruzada y análisis de importancia. Esta fase visual actúa como una primera capa evaluadora, especialmente útil para identificar transformaciones necesarias y comprender el comportamiento del target dentro del rango de cada variable.

    Finalmente, el establecimiento de estas variables en un patrón de normalización común, nos ayudará a observar/contrastarlas entre ellas, de una manera más objetiva. Esto lo veemos en el apartado de preprocesado previo a la ingesta a modelización.

    In [13]:
    # Heatmap de Correlación entre Variables Continuas:
    
    plt.figure(figsize=(20, 10))
    corr_matrix = train[continuous_vars].corr()
    sns.heatmap(corr_matrix, annot=True, cmap="Blues", fmt=".2f", linewidths=0.5)
    plt.title("Mapa de Calor de Correlación entre Variables Continuas")
    plt.show()
    
    No description has been provided for this image

    El mapa muestra, en general, bajos niveles de correlación lineal entre las variables cuantitativas consideradas, con la excepción de la pareja ps_car_12 y ps_car_13, que presentan una correlación moderada (0.67). Este hallazgo sugiere un bajo riesgo de multicolinealidad estructural, lo cual refuerza la robustez del modelo frente a redundancias informativas.

    Este resultado proporciona una base sólida para el uso de métricas complementarias como el Variance Inflation Factor (VIF) y el Kolmogorov-Smirnov (KS) en etapas posteriores del pipeline de modelado, con el objetivo de confirmar la independencia relativa de las variables y su capacidad discriminativa frente al target.

    3. Preparación de datos

    Valores N/A

    In [14]:
    # Constancia situacional a nivel viaual de lo que representa las variables a nivel N/A en el conjunto de datos:
    
    plt.figure(figsize=(25, 10))
    sns.heatmap(train.replace(-1,np.nan).isnull(), cmap="Blues", cbar=False, yticklabels=False)
    plt.title("Mapa de Valores Faltantes (-1 tratados como NaN)")
    plt.show()
    
    No description has been provided for this image

    En este conjunto de datos, los valores faltantes están codificados como -1. A pesar de que algunas variables presentan una elevada proporción de datos nulos, se ha optado por no eliminarlas del análisis.

    La razón de esta decisión se fundamenta en el contexto del problema: estamos modelizando el riesgo de siniestralidad. En este ámbito, descartar variables informativas por el mero hecho de tener valores faltantes puede implicar una pérdida significativa de capacidad predictiva. De hecho, la presencia de valores faltantes puede ser indicativa de un patrón subyacente o comportamiento anómalo del asegurado.

    Por ello, se adopta un enfoque diferenciado según la naturaleza de la variable:

    • Sobre variables continuas (tales como ps_reg_03, ps_car_12, ps_car_14 ... Como veremos a continuación) se imputan usando la mediana, y se normalizan posteriormente mediante QuantileTransformer hacia una distribución normal. Esta combinación permite mantener la información del contrato, sin introducir distorsión significativa en la distribución.
    • Las variables categóricas conservan el valor -1 como una categoría explícita. Esto permite identificar posibles patrones asociados a la falta de información, tratándolos como una categoría más en el modelado.

    Este enfoque evita la pérdida de información valiosa (mediante eliminación de registros) y respeta la naturaleza original del dataset. La imputación y normalización aplicada en variables continuas busca reducir el sesgo introducido por los valores faltantes sin distorsionar las relaciones originales del modelo. Se evita la eliminación de columnas con NAs para preservar tanto la capacidad predictiva como la representatividad de los datos.

    In [15]:
    # Visualización ordenada de variables n/a:
    
    train_na = (train == -1).sum()
    train_na = train_na[train_na > 0]
    train_na = train_na.sort_values(ascending=False)
    
    df_na = pd.DataFrame({'Variable': train_na.index, 'num_na': train_na.values})
    df_na = df_na.sort_values(by="num_na", ascending=False)
    
    df_na
    
    Out[15]:
    Variable num_na
    0 ps_car_03_cat 411231
    1 ps_car_05_cat 266551
    2 ps_reg_03 107772
    3 ps_car_14 42620
    4 ps_car_07_cat 11489
    5 ps_ind_05_cat 5809
    6 ps_car_09_cat 569
    7 ps_ind_02_cat 216
    8 ps_car_01_cat 107
    9 ps_ind_04_cat 83
    10 ps_car_02_cat 5
    11 ps_car_11 5
    12 ps_car_12 1

    Comparativa de métodos de imputación para variables cuantitativas

    Dado el contexto del caso de uso y cómo se pretente abordar los NA's según el tipo de variable, se requiere imputar valores ausentes en variables continuas sin introducir sesgos significativos. A continuación, se presenta un análisis comparativo de los principales métodos de imputación, con foco en su aplicabilidad al dataset actual (tabular, no temporal, con presencia de outliers y distribución sesgada).

    Método Ventajas Desventajas
    Media (mean) Fácil de calcular, útil si la variable sigue distribución normal Muy sensible a outliers y a distribuciones asimétricas
    Mediana (median) SELECCIONADA Robusta ante outliers y asimetría; conserva representatividad central Puede ignorar información si la variable tiene estructura multimodal
    KNN Imputation Aprovecha la correlación entre variables; imputación más precisa Costoso computacionalmente y sensible al escalado y ruido
    Interpolación temporal (ffill/bfill) Mantiene secuencia y dinámica temporal Inaplicable en datos no ordenados temporalmente

    En este caso concreto, se opta por imputar los valores ausentes mediante la mediana, dada su robustez frente a valores extremos y su adecuación a variables sesgadas, a banda de la sencillez que supone su imputación. Tras la imputación, se aplica QuantileTransformer para convertir la variable a una distribución normal, lo cual favorece el rendimiento de modelos que suponen normalidad o requieren escalado en un futuro.

    In [16]:
    # Imputación de variables cuant. con NAs:
    
    train_before = train.copy()
    train[continuous_vars] = train[continuous_vars].replace(-1, np.nan)
    
    # Imputo los NaN con la mediana, para a posteriori poder aplicar el quantile imputer:
    normal_dist = QuantileTransformer(output_distribution='normal', random_state=42)
    train[['ps_reg_03', 'ps_car_12', 'ps_car_14']] = train[['ps_reg_03', 'ps_car_12', 'ps_car_14']].fillna(train[['ps_reg_03', 'ps_car_12', 'ps_car_14']].median())
    train[['ps_reg_03', 'ps_car_12', 'ps_car_14']] = normal_dist.fit_transform(train[['ps_reg_03', 'ps_car_12', 'ps_car_14']])
    
    # Histogramas Antes vs Después de la Imputación ---
    fig, axes = plt.subplots(3, 2, figsize=(16, 10))
    
    for i, col in enumerate(['ps_reg_03', 'ps_car_12', 'ps_car_14']):
    
        sns.histplot(train_before[col].dropna(), kde=True, bins=30, ax=axes[i, 0], color="royalblue", alpha=0.6)
        axes[i, 0].set_title(f"Distribución de {col} Antes de la Imputación")
    
        sns.histplot(train[col], kde=True, bins=30, ax=axes[i, 1], color="darkblue", alpha=0.6)
        axes[i, 1].set_title(f"Distribución de {col} Después de la Imputación")
    
    plt.tight_layout()
    plt.show()
    
    No description has been provided for this image
    In [17]:
    # Volvemos a visualizar la distr de NAs del dataset:
    train.info()
    
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 595212 entries, 0 to 595211
    Data columns (total 59 columns):
     #   Column          Non-Null Count   Dtype  
    ---  ------          --------------   -----  
     0   id              595212 non-null  int64  
     1   target          595212 non-null  int64  
     2   ps_ind_01       595212 non-null  int64  
     3   ps_ind_02_cat   595212 non-null  int64  
     4   ps_ind_03       595212 non-null  int64  
     5   ps_ind_04_cat   595212 non-null  int64  
     6   ps_ind_05_cat   595212 non-null  int64  
     7   ps_ind_06_bin   595212 non-null  int64  
     8   ps_ind_07_bin   595212 non-null  int64  
     9   ps_ind_08_bin   595212 non-null  int64  
     10  ps_ind_09_bin   595212 non-null  int64  
     11  ps_ind_10_bin   595212 non-null  int64  
     12  ps_ind_11_bin   595212 non-null  int64  
     13  ps_ind_12_bin   595212 non-null  int64  
     14  ps_ind_13_bin   595212 non-null  int64  
     15  ps_ind_14       595212 non-null  int64  
     16  ps_ind_15       595212 non-null  int64  
     17  ps_ind_16_bin   595212 non-null  int64  
     18  ps_ind_17_bin   595212 non-null  int64  
     19  ps_ind_18_bin   595212 non-null  int64  
     20  ps_reg_01       595212 non-null  float64
     21  ps_reg_02       595212 non-null  float64
     22  ps_reg_03       595212 non-null  float64
     23  ps_car_01_cat   595212 non-null  int64  
     24  ps_car_02_cat   595212 non-null  int64  
     25  ps_car_03_cat   595212 non-null  int64  
     26  ps_car_04_cat   595212 non-null  int64  
     27  ps_car_05_cat   595212 non-null  int64  
     28  ps_car_06_cat   595212 non-null  int64  
     29  ps_car_07_cat   595212 non-null  int64  
     30  ps_car_08_cat   595212 non-null  int64  
     31  ps_car_09_cat   595212 non-null  int64  
     32  ps_car_10_cat   595212 non-null  int64  
     33  ps_car_11_cat   595212 non-null  int64  
     34  ps_car_11       595212 non-null  int64  
     35  ps_car_12       595212 non-null  float64
     36  ps_car_13       595212 non-null  float64
     37  ps_car_14       595212 non-null  float64
     38  ps_car_15       595212 non-null  float64
     39  ps_calc_01      595212 non-null  float64
     40  ps_calc_02      595212 non-null  float64
     41  ps_calc_03      595212 non-null  float64
     42  ps_calc_04      595212 non-null  int64  
     43  ps_calc_05      595212 non-null  int64  
     44  ps_calc_06      595212 non-null  int64  
     45  ps_calc_07      595212 non-null  int64  
     46  ps_calc_08      595212 non-null  int64  
     47  ps_calc_09      595212 non-null  int64  
     48  ps_calc_10      595212 non-null  int64  
     49  ps_calc_11      595212 non-null  int64  
     50  ps_calc_12      595212 non-null  int64  
     51  ps_calc_13      595212 non-null  int64  
     52  ps_calc_14      595212 non-null  int64  
     53  ps_calc_15_bin  595212 non-null  int64  
     54  ps_calc_16_bin  595212 non-null  int64  
     55  ps_calc_17_bin  595212 non-null  int64  
     56  ps_calc_18_bin  595212 non-null  int64  
     57  ps_calc_19_bin  595212 non-null  int64  
     58  ps_calc_20_bin  595212 non-null  int64  
    dtypes: float64(10), int64(49)
    memory usage: 267.9 MB
    
    In [18]:
    # ratificación tras la imputación:
    
    train_na = (train == -1).sum()
    train_na = train_na[train_na > 0]
    train_na = train_na.sort_values(ascending=False)
    
    df_na = pd.DataFrame({'Variable': train_na.index, 'num_na': train_na.values})
    df_na = df_na.sort_values(by="num_na", ascending=False)
    
    df_na
    
    Out[18]:
    Variable num_na
    0 ps_car_03_cat 411231
    1 ps_car_05_cat 266551
    2 ps_car_07_cat 11489
    3 ps_ind_05_cat 5809
    4 ps_car_09_cat 569
    5 ps_ind_02_cat 216
    6 ps_car_01_cat 107
    7 ps_ind_04_cat 83
    8 ps_car_02_cat 5
    9 ps_car_11 5

    Proceso de selección de variables. Reducción de la dimensionalidad del dataset de manera empírica:

    El presente proceso pretende constatar la importancia de cada variable en relación al target/evento definitorio de nuestro planteamiento potencial de cuantificación del riesgo. Para ello, el siguiente apartado entabla un análisis procedimentado a partir de una serie de pruebas estadísticas que, con referencia a la industria, nos aportan consciencia situacional acerca de si una variable "x" es relevante en relación con el evento que quiere explicar.

    Selección de Variables: Estrategia Combinada

    Dada la elevada dimensionalidad del dataset original, se plantea la necesidad de aplicar una estrategia de reducción de variables que permita:

    • Mejorar la interpretabilidad del modelo
    • Reducir el riesgo de sobreajuste
    • Facilitar la validación y el control del riesgo

    Para ello, se aplica un enfoque combinado de selección de variables, basado en criterios estadísticos tanto paramétricos como no paramétricos.

    1. Cálculo de indicadores por variable

    Se evalúa cada variable mediante diferentes indicadores de relevancia y estabilidad predictiva:

    • Gini index: derivado del AUC, mide la capacidad discriminativa respecto al target.
    • Information Value (IV): cuantifica la separación entre clases tras binning por cuantiles; muy usado en scoring.
    • Importancia por permutación: criterio no paramétrico basado en Random Forest para estimar sensibilidad del modelo.
    • Variance Inflation Factor (VIF): mide la multicolinealidad entre variables cuantitativas.
    • Kolmogorov-Smirnov (KS): mide la separación entre distribuciones del target en variables continuas.

    2. Agregación de métricas: Score de selección

    Para sintetizar la relevancia predictiva de cada variable, se construye un score agregado como:

    Score = Gini + Information Value
    

    Este score combina la capacidad discriminativa (gini) con la estabilidad predictiva (IV), integrando dos enfoques complementarios.

    3. Umbral óptimo de selección con el método de Otsu

    Para determinar un punto de corte que separe las variables relevantes de las irrelevantes, se aplica el método de Otsu sobre la distribución del score combinado. Este método, originalmente desarrollado para segmentación en procesamiento de imagen, permite encontrar el umbral que minimiza la varianza intra-grupo y maximiza la varianza entre grupos.

    El resultado es una dicotomización objetiva y reproducible del conjunto de variables, que permite seleccionar N variables óptimas para construir el dataset final.

    4. Resultado: Subconjunto de variables seleccionadas

    • Variables seleccionadas: aquellas con Score ≥ umbral_otsu
    • Variables descartadas: aquellas con Score < umbral_otsu

    Este subconjunto representa la versión controlada y reducida del dataset, sobre el cual se entrenarán los modelos de predicción de siniestralidad.

    Coeficiente de Gini

    El coeficiente de Gini es una métrica fundamental para evaluar la capacidad discriminante de una variable respecto a un objetivo binario. Altamente usasa en problemas de riesgo de crédito, extendemos su uso en el proyecto para evaluar el poder discriminante de las variables contra el target binario. La métrica, deriva del área bajo la curva ROC (AUC), siendo robusta al tomar en cuenta cuan sensible y específico es el clasificador, en términos de la matriz de confusión. Se calcula como:

    Gini = 2 · AUC - 1
    

    Este coeficiente refleja la capacidad de una variable para distinguir entre eventos (Y=1) y no eventos (Y=0), siendo especialmente, tal y como se ha comentado, útil en contextos de modelización del riesgo.

    Relación con el estadístico D de Somers'

    Estadísticamente, el coeficiente de Gini es equivalente al estadístico D de Somers', siendo por tanto una medida de asociación bidireccional (que tiene en cuenta el sentido de la ordenación) frente al target.

    D₍XY₎ = 2 · AUC - 1 = Gini
    

    Ambos miden el grado de concordancia entre el predictor y el target binario. A diferencia de la Tau de Kendall (simétrica), Somers' D es direccional y por tanto es más adecuado para evaluar la capacidad discriminante de una variable independiente sobre una dependiente.

    Interpretación numérica del Gini

    Valor Gini Interpretación
    0.00 - 0.10Sin capacidad predictiva
    0.10 - 0.30Baja capacidad predictiva
    0.30 - 0.50Buena capacidad predictiva
    0.50 - 0.70Muy buena capacidad predictiva
    0.70 - 1.00Excelente capacidad predictiva

    Aplicabilidad:

    • Tipo de variables: se aplicará sobre todas las variables, tanto cualitativas como cuantitativas. En el caso de variables multinivel, se realizará una transformación mediante one-hot encoding para evaluar el poder discriminante a nivel de cada categoría. Esta estrategia permite descomponer una variable categórica en múltiples variables binarias, capturando la información individual de cada nivel de forma explícita.
    • Tratamiento de valores faltantes: Dado que en este conjunto de datos los valores faltantes se codifican como -1, se realizará una conversión explícita a NaN antes de calcular el AUC. Esto es necesario porque la función roc_auc_score de scikit-learn no admite NaN como entrada, y porque mantener el -1 podría introducir un sesgo significativo en la medición de la capacidad discriminativa de la variable.
    • Criterio de selección: El coeficiente de Gini no se utilizará como umbral absoluto según los valores establecidos en la literatura (e.g., Gini > 0.3 indica buena capacidad predictiva), sino como una métrica relativa dentro del conjunto de variables analizadas.

    Este procedimiento permite identificar variables que presentan una estructura informativa/discriminante relevante respecto al target, incluso si su relación no es lineal ni monotónica. Su valor añadido radica en que permite evaluar la capacidad discriminativa sin asumir ningún modelo subyacente.

    In [19]:
    def gini_01(df, target, binary_vars, categorical_vars, continuous_vars):
        """
        Calcula el coeficiente de Gini para cada variable del dataset según su tipo:
        - Continuas: AUC-ROC -> Gini = 2 * AUC - 1
        - Binarias: AUC-ROC -> Gini = 2 * AUC - 1
        - Categóricas: One-hot encoding y promedio del Gini de cada categoría.
        """
        # Imputación de n/a real:
        df = df.replace(-1, np.nan)  # Reemplazo -1 por NaN para que no se tengan en cuenta, pero NO los elimino (efecto solo contra cualitativas)
        
        gini_dict = {}
    
        # Variables binarias y continuas: usamos AUC-ROC directamente
        for col in binary_vars + continuous_vars:
            try:
                auc = roc_auc_score(df[target], df[col])
                gini_dict[col] = 2 * auc - 1
            except:
                gini_dict[col] = np.nan 
    
        # Variables categóricas: One-hot encoding y cálculo de Gini por categoría
        for col in categorical_vars:
            temp_df_dummies = pd.get_dummies(df[col], prefix=col, drop_first=True)
            gini_scores = []
            for dummy_col in temp_df_dummies.columns:
                try:
                    auc = roc_auc_score(df[target], temp_df_dummies[dummy_col])
                    gini_scores.append(2 * auc - 1)
                except:
                    gini_scores.append(np.nan)
            
            gini_dict[col] = np.nanmean(gini_scores)
    
        gini_df = pd.DataFrame(list(gini_dict.items()), columns=['Feature', 'Gini'])
        gini_df = gini_df.sort_values(by='Gini', ascending=False)
    
        return gini_df
    
    
    def plot_gini_results(gini_df):
        """
        Visualiza el coeficiente de Gini en un gráfico de barras.
        """
        gini_df = gini_df.sort_values(by="Gini", ascending=False)
        gini_thresholds = {
            "Neutro": 0,
        }
        plt.figure(figsize=(20, 14))
        color = (0/255, 155/255, 200/255)
    
        ax = sns.barplot(y=gini_df["Feature"], x=gini_df["Gini"], color=color)
        for label, value in gini_thresholds.items():
            plt.axvline(x=value, color="red", linestyle="dashed", linewidth=2, label=f"{label} ({value:.2f})")
    
        plt.xlabel("Gini", fontsize=12)
        plt.ylabel("Feature", fontsize=12)
        plt.title("Gini de las Variables", fontsize=14, fontweight="bold")
    
        plt.legend()
        plt.show()
    
    
    # --------------------------------------------------
    #---------EJECUCIÓN y PLOT--------------------------
    # --------------------------------------------------
    gini_total = gini_01(train, target, binary_vars, categorical_vars, continuous_vars)
    plot_gini_results(gini_total)
    
    No description has been provided for this image

    Information Value (IV)

    El Information Value (IV), junto con el Weight of Evidence (WoE), es una de las métricas más utilizadas para evaluar la capacidad predictiva de una variable independiente respecto a una variable objetivo binaria. Estas métricas, derivadas de la teoría de la información, son ampliamente aplicadas en contextos de riesgo de crédito y scoring de carteras/clientes.

    Definición formal del Weight of Evidence (WoE)

    El WoE mide el grado de separación entre las distribuciones de las dos clases de la variable objetivo (Y=1 vs Y=0) dentro de cada grupo o bin de una variable independiente X. Se calcula como:

    WoEⱼ = ln( (N₁ⱼ / N₁) / (N₀ⱼ / N₀) )
    
    • N₁ⱼ: número de eventos (Y=1) en el grupo j
    • N₀ⱼ: número de no eventos (Y=0) en el grupo j
    • N₁, N₀: totales de eventos y no eventos en el dataset

    El WoE tiene propiedades clave:

    • WoE = 0 indica proporciones idénticas entre clases (no información)
    • WoE > 0: mayor peso del grupo en la clase positiva
    • WoE < 0: mayor peso del grupo en la clase negativa

    Cálculo e interpretación del Information Value (IV)

    El IV mide la capacidad global de una variable para discriminar entre eventos y no eventos. Se define como:

    IV = Σ ( (N₁ⱼ / N₁ - N₀ⱼ / N₀) · WoEⱼ )
    

    La interpretación nominal clásica del IV es la siguiente:

    Valor IV Interpretación
    IV < 0.02No informativa
    0.02 < IV < 0.1Débilmente predictiva
    0.1 < IV < 0.3Moderadamente predictiva
    IV > 0.3Altamente predictiva

    Aplicabilidad:

    • Tipo de variables: se aplicará sobre todas las variables, tanto cualitativas como cuantitativas.
    • Tratamiento de valores faltantes: se preservan los nulos reales (convertidos a NaN), diferenciándolos del valor -1. Esta elección permite que el modelo distinga efectivamente los casos con ausencia de información sin introducir sesgo al tratar -1 como una categoría legítima.
    • Transformación: las variables continuas se binarizarán mediante binning cuantílico (percentiles) para obtener grupos que preserven la distribución y permitan un cálculo estable del WoE e IV.
    • Criterio de selección: el IV se utilizará como métrica relativa en el conjunto de variables, no como umbral absoluto, tal y como se ha hecho en el caso del Gini.

    Este procedimiento permite identificar variables que presentan una estructura informativa relevante respecto al target, incluso si su relación no es lineal ni monotónica. Su valor añadido radica en que permite evaluar la capacidad discriminativa sin asumir ningún modelo subyacente.

    In [20]:
    # --------------------------------------------------------------
    # 2. Information Value (IV)-------------------------------------
    # --------------------------------------------------------------
    
    def iv_02(df, target_col, binary_vars, categorical_vars, continuous_vars, num_bins=10):
        """
        Calcula el Information Value (IV) para cada variable respecto al target.
        - Variables continuas: binning (división en num_bins partes).
        - Variables binarias y categóricas: proporciones de la variable target en cada categoría.
    
        Parámetros:
        - df: DataFrame con los datos.
        - target_col: Nombre de la variable objetivo.
        - binary_vars: Lista de variables binarias.
        - categorical_vars: Lista de variables categóricas.
        - continuous_vars: Lista de variables continuas.
        - num_bins: Número de bins para variables continuas.
    
        Retorno:
        - iv_df: DataFrame con los valores IV de cada variable.
        """
        iv_dict = {}
    
        df = df.drop(columns=['id'], axis=1)
        
        for col in df.columns:
            if col == target_col:
                continue
    
            temp_df = df[[col, target_col]].copy()
    
            # Reemplazo valores -1 con NaN para tratar datos ausentes pero NO elimino (misma lógica que gini):
            temp_df[col] = temp_df[col].replace(-1, np.nan)
    
            # Aplico binning solo a variables continuas (alnum_bins =10 son deciles)
            if col in continuous_vars:
                temp_df[col] = pd.qcut(temp_df[col], q=num_bins, duplicates="drop")
    
            # Tabla de frecuencia de eventos y no eventos
            iv_table = temp_df.groupby(col, observed=False)[target_col].agg(['count', 'sum'])
            iv_table.columns = ['Total', 'Events']
            iv_table['Non-Events'] = iv_table['Total'] - iv_table['Events']
    
            # Cálculo % de eventos y % de no eventos en cada grupo
            iv_table['% Events'] = iv_table['Events'] / iv_table['Events'].sum()
            iv_table['% Non-Events'] = iv_table['Non-Events'] / iv_table['Non-Events'].sum()
            iv_table['% Events'] = np.where(iv_table['% Events'] == 0, 0.0001, iv_table['% Events'])
            iv_table['% Non-Events'] = np.where(iv_table['% Non-Events'] == 0, 0.0001, iv_table['% Non-Events'])
    
            # WoE
            iv_table['WOE'] = np.log(iv_table['% Non-Events'] / iv_table['% Events'])
    
            # IV
            iv_table['IV'] = (iv_table['% Non-Events'] - iv_table['% Events']) * iv_table['WOE']
            iv_dict[col] = iv_table['IV'].sum()
    
        iv_df = pd.DataFrame(list(iv_dict.items()), columns=['Feature', 'Information Value (IV)'])
        iv_df = iv_df.sort_values(by='Information Value (IV)', ascending=False)
    
        return iv_df
    
    
    def plot_iv_results(iv_df):
        """
        Visualiza el Information Value (IV) de las variables en un gráfico de barras.
        """
        iv_df = iv_df.sort_values(by="Information Value (IV)", ascending=False)
    
        iv_thresholds = {
            "Bajo": 0.005,
            "Medio": 0.01,
            "Alto": 0.05
        }
    
        plt.figure(figsize=(20, 14))
        color = (0/255, 155/255, 200/255) 
    
        ax = sns.barplot(y=iv_df["Feature"], x=iv_df["Information Value (IV)"], color=color)
        for label, value in iv_thresholds.items():
            plt.axvline(x=value, color="red", linestyle="dashed", linewidth=2, label=f"{label} ({value:.2f})")
    
        plt.xlabel("Information Value (IV)", fontsize=12)
        plt.ylabel("Feature", fontsize=12)
        plt.title("Information Value (IV) de las Variables", fontsize=14, fontweight="bold")
    
        plt.legend()
        plt.show()
    
    
    # --------------------------------------------------
    #---------EJECUCIÓN y PLOT--------------------------
    # --------------------------------------------------
    iv_total = iv_02(train, target, binary_vars, categorical_vars, continuous_vars)
    plot_iv_results(iv_total)
    
    No description has been provided for this image

    Importancia por Permutación (A partir de RF Naive)

    La Importancia por Permutación es una técnica ampliamente adoptada en Machine Learning para evaluar la relevancia de cada variable en la predicción del modelo. Su principio es simple pero de ahí radica su potencia: desordenar (permutar aleatoriamente) los valores de una variable y observar la degradación del rendimiento del modelo.

    Este método es especialmente eficaz en modelos no lineales como Random Forest, Gradient Boosting o Redes Neuronales, ya que permite capturar relaciones complejas sin asumir estructuras paramétricas. No obstante, en el presente caso de uso, se usará Random Forest, si bien la elección no sigue nungún prejuicio más que tener un mismo modelo sobre el que contrastar el deterioro del predictor de cara a evaluar la importancia.

    Definición formal

    Sea \( f \) el modelo entrenado, y \( M(f) \) una métrica de rendimiento (por ejemplo, AUC, accuracy o RMSE). La importancia de la variable \( X_j \) se define como:

    Imp(Xⱼ) = M(f) - M(f_{π(Xⱼ)})
    
    • M(f): rendimiento original del modelo
    • M(f_{π(Xⱼ)}): rendimiento tras permutar aleatoriamente los valores de Xⱼ
    • π(Xⱼ): permutación aleatoria de los valores de la variable Xⱼ

    Si la métrica del modelo se reduce significativamente tras la permutación, la variable es considerada informativa. Si la métrica se mantiene constante, la variable es irrelevante para el modelo.


    Aplicabilidad:

    • Tipo de variables: todas las variables del dataset, tanto cualitativas (binarizadas) como cuantitativas, son incluidas en la evaluación.
    • Tratamiento de valores faltantes: se preservan los nulos reales (convertidos a NaN), diferenciándolos del valor -1. Esta elección permite que el modelo distinga efectivamente los casos con ausencia de información sin introducir sesgo al tratar -1 como una categoría legítima.
    • Criterio de uso: la importancia obtenida se utilizará como medida relativa para comparar variables entre sí dentro del conjunto, sin aplicar un umbral absoluto. Este enfoque se alinea con la filosofía aplicada previamente al Information Value y al Gini, buscando consistencia metodológica en la selección final de atributos relevantes.
    In [21]:
    # --------------------------------------------------------------
    # 3. Permutation Importance ------------------------------------
    # --------------------------------------------------------------
    
    def importance_03(df):
        """
        Cálculo de la Importancia por permutación basada en resultados de RF naive.
    
        Parámetros:
        df (pd.DataFrame): DataFrame con variables numéricas.
        
        Retorna:
        pd.DataFrame: DataFrame con los valores de Peerm.Imp de cada variable.
        """
    
        # Separación de features y target
        X = df.drop(columns=['target', 'id']).replace(-1, np.nan)  # Reemplazo -1 por NaN y NO elimino
        y = df['target']
    
        # Defino el modelo naive sin hiperparámetros optimizados:
        model = RandomForestClassifier(
            n_estimators=20, 
            max_depth=8,  
            max_features="sqrt",  
            bootstrap=True,  
            n_jobs=-1,  
            random_state=42
        )
        model.fit(X, y)
    
        # Calculo la Permutation Importance:
        perm_importance = permutation_importance(
            model, X, y, 
            n_repeats=5,  
            random_state=42, 
            scoring='accuracy',
            n_jobs=-1 
        )
    
        perm_importance_df = pd.DataFrame({
            'Feature': X.columns, 
            'Permutation Importance': (perm_importance.importances_mean*10000) 
            # multiplico por factor 1·10^4, solo a efectos de comparación relativa
        }).sort_values(by='Permutation Importance', ascending=False)
    
        return perm_importance_df
    
    
    def plot_permutation_importance_results(perm_importance_df):
        """
        Visualiza la Permutation Importance de las variables en un gráfico de barras.
        """
        plt.figure(figsize=(20, 14)) 
        color = (0/255, 155/255, 200/255)  
    
        ax = sns.barplot(y=perm_importance_df["Feature"], x=perm_importance_df["Permutation Importance"], color=color)
        plt.xlabel("Permutation Importance", fontsize=12)
        plt.ylabel("Feature", fontsize=12)
        plt.title("Permutation Importance de las Variables", fontsize=14, fontweight="bold")
        plt.show()
    
    
    # --------------------------------------------------
    #---------EJECUCIÓN Y PLOT--------------------------
    # --------------------------------------------------
    permutation_total = importance_03(train)
    plot_permutation_importance_results(permutation_total)
    
    No description has been provided for this image

    Factor de Inflación de la Varianza (VIF)

    El Factor de Inflación de la Varianza (VIF) es una métrica que evalúa el grado de multicolinealidad entre variables predictoras en un modelo de regresión.al cuantifica cuánto se incrementa la varianza de los coeficientes estimados debido a la correlación entre variables independientes. En nuestro contexto, tiene sentido observar si existe multicolinealidad entre las variables cuantitativas de cara a evitar un potencial sesgo por esta razón cuando se adentren las variables en un modelo y puedan interaccionar.

    Fórmula del VIF

    Sea \( R_j^2 \) el coeficiente de determinación de la regresión lineal de la variable \( X_j \) contra todas las demás variables. Entonces:

    VIFⱼ = 1 / (1 - Rⱼ²)
    

    Interpretación del VIF

    Valor VIF Interpretación
    VIF < 5Multicolinealidad baja
    5 ≤ VIF < 10Multicolinealidad moderada
    VIF ≥ 10Multicolinealidad alta (problema grave)

    Aplicabilidad:

    • Tipo de variables: únicamente se aplicará sobre variables continuas, ya que el concepto de varianza inflada se define en el contexto de regresión lineal.
    • Tratamiento de valores nulos: se eliminan los registros con valores NaN en las variables continuas antes del cálculo del VIF, para evitar errores numéricos.
    • Criterio de selección: aquellas variables que presenten VIF elevados serán eliminadas del conjunto final de predictores, con el objetivo de reducir la redundancia y mejorar la estabilidad del modelo.
    In [22]:
    # --------------------------------------------------------------
    # 4. Variance Inflation Factor (VIF) ---------------------------
    # --------------------------------------------------------------
    
    def vif_04(df):
        """
        Calcula el Variance Inflation Factor (VIF) para detectar colinealidad entre variables continuas.
        - Elimina columnas con varianza cero para evitar errores.
        - Maneja valores nulos eliminando filas con NaN antes de calcular el VIF.
    
        Parámetros:
        df (pd.DataFrame): DataFrame con variables numéricas.
    
        Retorna:
        pd.DataFrame: DataFrame con los valores de VIF de cada variable.
        """
        # Elimino columnas constantes (varianza cero)
        df = df.loc[:, df.var() > 0]
    
        # Quito filas con valores n/a y control:
        df = df.replace(-1, np.nan).dropna() # En continuas, elimino N/A's, para que no afecten a la distribucioón
        if df.shape[1] == 0:
            return pd.DataFrame(columns=["Feature", "VIF"])
    
        # Calculo VIF
        vif_data = pd.DataFrame()
        vif_data["Feature"] = df.columns
        vif_data["VIF"] = [variance_inflation_factor(df.values, i) for i in range(df.shape[1])]
    
        return vif_data
    
    def plot_vif_results(df):
        """
        Visualiza el VIF de las variables en un gráfico de barras.
        """
        plt.figure(figsize=(20, 6)) 
        color = (0/255, 155/255, 200/255)
    
        ax = sns.barplot(y=df["Feature"], x=df["VIF"], color=color)
        plt.xlabel("VIF", fontsize=12)
        plt.ylabel("Feature", fontsize=12)
        plt.title("VIF de las Variables", fontsize=14, fontweight="bold")
        plt.show()
    
    
    
    # --------------------------------------------------
    #---------EJECUCIÓN (solo varibles continuas) ----
    # --------------------------------------------------
    scaler = StandardScaler()
    if continuous_vars:
        X_continuous = train[continuous_vars].copy().replace(-1, np.nan).dropna()
        
        if X_continuous.shape[1] > 1:
            X_continuous_scaled = scaler.fit_transform(X_continuous)
        else:
            X_continuous_scaled = X_continuous.values
        vif_df = vif_04(pd.DataFrame(X_continuous_scaled, columns=X_continuous.columns))
    else:
        vif_df = pd.DataFrame(columns=["Feature", "VIF"])
    
    # VIF descendente
    vif_total = vif_df.sort_values(by="VIF", ascending=False).reset_index(drop=True)
    vif_total
    
    plot_vif_results(vif_total)
    
    No description has been provided for this image

    Kolmogorov-Smirnov (KS)

    El test de Kolmogorov-Smirnov (KS) es una prueba no paramétrica utilizada para evaluar si dos muestras provienen de la misma distribución. En el contexto de modelos de clasificación binaria, como es nuestro caso, se emplea para medir la capacidad discriminante de una variable continua entre dos clases: eventos (Y=1) y no eventos (Y=0). El estadístico KS se basa en la diferencia máxima entre las funciones de distribución acumulada (CDF) de ambas clases:

    KS = max |F₁(x) - F₀(x)|
    
    • F₁(x): CDF empírica de los registros con Y=1
    • F₀(x): CDF empírica de los registros con Y=0

    Cuanto mayor es el valor de KS, mayor es la separación entre las dos distribuciones y, por tanto, mayor es la capacidad de la variable para discriminar entre clases. Un valor de KS cercano a cero indica que la variable no aporta información relevante para distinguir entre eventos y no eventos.


    Aplicabilidad:

    • Tipo de variables: el test se aplicará exclusivamente a variables continuas, ya que su definición se basa en funciones de distribución acumulada.
    • Tratamiento de valores nulos: se eliminan los registros con valores NaN en las variables continuas antes del cálculo del estadístico, para no alterar la forma de las distribuciones.
    In [23]:
    # --------------------------------------------------------------
    # 5. Kolmogorov-Smirnov (KS Test) ------------------------------
    # --------------------------------------------------------------
    
    def ks_test(df, target, continuous_vars):
        """
        Calcula el estadístico KS para variables continuas.
        
        Parámetros:
        - df: DataFrame con los datos.
        - target: Variable objetivo binaria.
        - continuous_vars: Lista de variables continuas.
        
        Retorna:
        - DataFrame con los valores KS ordenados en orden descendente.
        """
        ks_results = []
        temp_df = df.copy().replace(-1, np.nan).dropna()  # KS no soporta NaN, y los elimino para que no afecten a la distribución.
        
        for col in continuous_vars:
            ks_stat, _ = ks_2samp(temp_df[col][temp_df[target] == 1], temp_df[col][temp_df[target] == 0])
            ks_results.append({"Feature": col, "KS": ks_stat})
        
        ks_df = pd.DataFrame(ks_results).sort_values(by="KS", ascending=False).reset_index(drop=True)
    
        return ks_df
    
    def plot_ks_results(ks_df):
        """
        Visualiza el Kolmogorov-Smirnov (KS) de las variables en un gráfico de barras.
        """
        plt.figure(figsize=(20, 6))  
        color = (0/255, 155/255, 200/255) 
    
        ax = sns.barplot(y=ks_df["Feature"], x=ks_df["KS"], color=color)
        plt.xlabel("Kolmogorov-Smirnov (KS)", fontsize=12)
        plt.ylabel("Feature", fontsize=12)
        plt.title("Kolmogorov-Smirnov (KS) de las Variables", fontsize=14, fontweight="bold")
        plt.show()
    
    
    # --------------------------------------------------
    #---------EJECUCIÓN (solo variables continuas) -----
    # --------------------------------------------------
    ks_total=ks_test(train, target, continuous_vars)
    plot_ks_results(ks_total)
    
    No description has been provided for this image

    Resumen del proceso de selección y justificación:

    El proceso de selección de variables cuenta con la finalidad de reducir el espacio dimensional de entrada a la solucion predictiva, con el objetivo de reducir la complejidad dimensional trasladada por un dataset ofuscado. En línea con el proceso, se ha evaluado el Gini, el Information Value y la Importancia por Permutación sobre todos los predictores, así como el VIF y el test de KS para solamente aquellos predictores continuos.

    Iniciando precisamente por estos últimos, vemos como tanto el VIF como el KS, no muestran evidencias que nos lleven a considerar excluir alguno de los predictores contínuos de nuestro conjunto de datos. Todos poseen un VIF y un KS moderadamente bajo.

    En lo relativo a las métricas puramente de cuantificación de importancia, (Gini, IV y Imp. por permutación) y su resultado, se va a proceder de manera totalmente objetiva en base a la siguiente consideración:

    Poseemos una "long-list" con todos los predictores y el objetivo es generar una short-list con los que hayan demostrado mejor performance en el proceso de selección.
    

    Por ello, a continuación, en los siguientes apartados, se muestra un tabular con el resultado de cada test para cada predictor. Se propone una serie de umbrales para marcar visualmente aquellos con un mejor performance. No obstante, para realizar una selección con fundamento empírico, vamos a tomar como ejes principales el Gini y el IV, pues son dos tests que basan su desempeño en la capacidad de separación de clases, ambos informándonos del poder discriminante de las variables. Por ello, generamos una métrica adicional, de evaluación:

    Sum Gini + IV: con la suma de abs(Gini), dado su caracter bidireccional y el IV.

    Sum Gini + IV + Perm: ídem a la anterior pero incorpora el resultado de Importancia por permutación.

    En definitiva, se procede a todos los efectos con el resultado en términso de "rankeado2" que nos proporciona Sum Gini + IV, dado que son los métodos más explicables y trazables a efectos cuantitativos. No obstante y, a modo de evaluación de la robustez del método, se contrasta el efecto adicional de la Importancia por permutación trazada a partir del Random Forest "naive". En particular se ve como a nivel genérico, existe una dependencia lineal entre Sum Gini + IV y Sum Gini + IV + Perm, lo que nos es indicativo como la Importancia por permutación capta una sensibilidad similar a ala de únicamente Gini e IV.

    Finalmente, dada esta dependencia, se procede con Sum Gini + IV, a efectos de generar la Short List de entrada a Modelo. El paso que nos deberemos plantgear, dado un ranking de variables ordenados por esta métrica de evaluación agregada, es "dónde" acotar el ranking. Esa tarea, precisamente es la que sigue previamente a la modelización, siempre con un objetivo de proporcionar un umbral de corte basado en los datos y pot tanto cuantitativo, evitando el "sentido Experto" que puede ser un criterio vago frente a un conjunto de datos ofuscado, sobre el que no se tiene información de negocio.

    In [24]:
    # Creación de un DataFrame vacío para almacenar los resultados consolidados del análisis:
    final_df = pd.DataFrame()
    
    # Unión condicional y robusta si estos existen:
    if 'gini_total' in locals():
        final_df = gini_total
    if 'iv_total' in locals():
        final_df = final_df.merge(iv_total, on="Feature", how="outer") if not final_df.empty else iv_total
    if 'permutation_total' in locals():
        final_df = final_df.merge(permutation_total, on="Feature", how="outer") if not final_df.empty else permutation_total
    if 'vif_total' in locals():
        final_df = final_df.merge(vif_total, on="Feature", how="outer") if not final_df.empty else vif_total
    if 'ks_total' in locals():
        final_df = final_df.merge(ks_total, on="Feature", how="outer") if not final_df.empty else ks_total
    
    # Muestro:
    final_df
    
    Out[24]:
    Feature Gini Information Value (IV) Permutation Importance VIF KS
    0 ps_calc_01 1.001044e-04 6.755897e-04 0.006720 NaN NaN
    1 ps_calc_02 4.726956e-04 4.922961e-04 0.013441 NaN NaN
    2 ps_calc_03 4.358367e-04 3.601328e-04 0.010080 NaN NaN
    3 ps_calc_04 1.581859e-04 1.469187e-04 0.000000 NaN NaN
    4 ps_calc_05 1.238698e-04 2.919458e-04 0.000000 NaN NaN
    5 ps_calc_06 1.743624e-07 9.493726e-04 0.026881 NaN NaN
    6 ps_calc_07 1.213284e-05 5.128267e-04 0.013441 NaN NaN
    7 ps_calc_08 1.743624e-06 4.088823e-04 0.013441 NaN NaN
    8 ps_calc_09 -8.423842e-05 2.967627e-04 0.000000 NaN NaN
    9 ps_calc_10 3.454968e-03 4.638004e-04 0.020161 1.000010 0.008748
    10 ps_calc_11 -7.620515e-07 1.392707e-03 0.023521 NaN NaN
    11 ps_calc_12 -2.098402e-04 5.833618e-04 0.006720 NaN NaN
    12 ps_calc_13 -1.113164e-04 2.539179e-04 0.020161 NaN NaN
    13 ps_calc_14 2.907907e-03 3.185615e-04 0.003360 1.000004 0.004217
    14 ps_calc_15_bin -8.577890e-04 6.867809e-06 0.000000 NaN NaN
    15 ps_calc_16_bin 1.609098e-03 1.109028e-05 0.000000 NaN NaN
    16 ps_calc_17_bin -4.512420e-04 8.240801e-07 0.006720 NaN NaN
    17 ps_calc_18_bin 1.333759e-03 8.678863e-06 0.013441 NaN NaN
    18 ps_calc_19_bin -4.435504e-03 8.682940e-05 0.000000 NaN NaN
    19 ps_calc_20_bin -2.061451e-03 3.290504e-05 0.000000 NaN NaN
    20 ps_car_01_cat -3.214540e-04 3.670219e-02 0.235210 NaN NaN
    21 ps_car_02_cat -6.322968e-02 2.522606e-02 -0.003360 NaN NaN
    22 ps_car_03_cat 6.276192e-02 1.026582e-02 0.114245 NaN NaN
    23 ps_car_04_cat 7.910204e-03 3.495814e-02 0.084004 NaN NaN
    24 ps_car_05_cat 3.309124e-02 1.963341e-05 0.010080 NaN NaN
    25 ps_car_06_cat 9.554776e-04 3.381718e-02 0.023521 NaN NaN
    26 ps_car_07_cat -4.448442e-02 9.582240e-03 0.144486 NaN NaN
    27 ps_car_08_cat -4.057461e-02 1.087619e-02 0.003360 NaN NaN
    28 ps_car_09_cat 6.846248e-03 1.678575e-02 0.016801 NaN NaN
    29 ps_car_10_cat 2.398088e-04 3.173514e-05 0.000000 NaN NaN
    30 ps_car_11 -4.398058e-03 1.275266e-02 0.000000 NaN NaN
    31 ps_car_11_cat 2.483150e-02 6.386553e-03 0.094084 1.003953 0.035152
    32 ps_car_12 1.103094e-01 3.995435e-02 0.057123 2.212193 0.083747
    33 ps_car_13 1.530936e-01 7.206788e-02 0.100804 1.913309 0.115247
    34 ps_car_14 2.394807e-02 1.879454e-02 0.026881 1.414189 0.061229
    35 ps_car_15 5.981435e-04 3.005593e-02 0.010080 NaN NaN
    36 ps_ind_01 4.136978e-03 1.176617e-02 0.003360 NaN NaN
    37 ps_ind_02_cat 4.465980e-03 1.078667e-03 0.235210 NaN NaN
    38 ps_ind_03 -1.781159e-03 2.879527e-02 0.060483 NaN NaN
    39 ps_ind_04_cat 2.607426e-02 2.912871e-03 0.369616 NaN NaN
    40 ps_ind_05_cat 8.701502e-03 2.999956e-02 0.332655 NaN NaN
    41 ps_ind_06_bin -8.868646e-02 3.459194e-02 0.067203 NaN NaN
    42 ps_ind_07_bin 7.979267e-02 3.081699e-02 0.087364 NaN NaN
    43 ps_ind_08_bin 2.597142e-02 4.658925e-03 0.050402 NaN NaN
    44 ps_ind_09_bin -1.707763e-02 1.999937e-03 0.023521 NaN NaN
    45 ps_ind_10_bin 1.869872e-04 7.717706e-05 0.000000 NaN NaN
    46 ps_ind_11_bin 4.447789e-04 1.049083e-04 0.000000 NaN NaN
    47 ps_ind_12_bin 4.029881e-03 1.468236e-03 0.000000 NaN NaN
    48 ps_ind_13_bin 4.039361e-04 1.454926e-04 0.000000 NaN NaN
    49 ps_ind_14 1.034105e-03 1.464092e-03 0.000000 NaN NaN
    50 ps_ind_15 -7.434278e-04 1.612273e-02 0.040322 NaN NaN
    51 ps_ind_16_bin -7.017560e-02 2.113447e-02 0.084004 NaN NaN
    52 ps_ind_17_bin 6.450002e-02 3.288829e-02 0.117605 NaN NaN
    53 ps_ind_18_bin 8.761238e-03 5.785738e-04 0.000000 NaN NaN
    54 ps_reg_01 -3.228884e-04 2.400182e-02 0.050402 NaN NaN
    55 ps_reg_02 6.342803e-04 4.186385e-02 0.040322 NaN NaN
    56 ps_reg_03 9.806298e-02 3.916688e-02 0.043682 1.057116 0.086947
    In [25]:
    # Definio umbrales (a efectos de viz siguiente) y no de filtrado, en función de valores obtenidos:
    
    gini_threshold= 0.01 # es (abs(gini))
    iv_threshold = 0.01 
    perm_threshold = 0.01 # está · 10^4
    
    vif_threshold = 5  
    ks_threshold = 0.1   
    
    In [26]:
    # Muestro:
    
    def highlight_values(val, column):
        if column == 'Gini':
            return 'background-color: #c8e6c9' if abs(val) >= gini_threshold else 'background-color: #ffcc80'
        elif column == 'Information Value (IV)':
            return 'background-color: #c8e6c9' if val >= iv_threshold else 'background-color: #ffcc80'
        elif column == 'Permutation Importance':
            return 'background-color: #c8e6c9' if val >= perm_threshold else 'background-color: #ffcc80'
    
        elif column == 'VIF':
            return 'background-color: #c8e6c9' if val <= vif_threshold else 'background-color: #ffcc80'
        elif column == 'KS':
            return 'background-color: #c8e6c9' if val >= ks_threshold else 'background-color: #ffcc80'
        
        return ''
    
    final_df[['Gini','Information Value (IV)','Permutation Importance', 'VIF', 'KS']] = final_df[['Gini', 'Information Value (IV)','Permutation Importance','VIF', 'KS']].round(5)
    
    # Aplico estilos para viz:
    styled_df = final_df.style.format(precision=5) \
                             .applymap(lambda val: highlight_values(val, 'Gini'), subset=['Gini']) \
                             .applymap(lambda val: highlight_values(val, 'Information Value (IV)'), subset=['Information Value (IV)']) \
                             .applymap(lambda val: highlight_values(val, 'Permutation Importance'), subset=['Permutation Importance']) \
                            #  .applymap(lambda val: highlight_values(val, 'VIF'), subset=['VIF']) \
                            #  .applymap(lambda val: highlight_values(val, 'KS'), subset=['KS'])
                            
    styled_df
    
    C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\1172576962.py:22: FutureWarning:
    
    Styler.applymap has been deprecated. Use Styler.map instead.
    
    C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\1172576962.py:23: FutureWarning:
    
    Styler.applymap has been deprecated. Use Styler.map instead.
    
    C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\1172576962.py:24: FutureWarning:
    
    Styler.applymap has been deprecated. Use Styler.map instead.
    
    
    Out[26]:
      Feature Gini Information Value (IV) Permutation Importance VIF KS
    0 ps_calc_01 0.00010 0.00068 0.00672 nan nan
    1 ps_calc_02 0.00047 0.00049 0.01344 nan nan
    2 ps_calc_03 0.00044 0.00036 0.01008 nan nan
    3 ps_calc_04 0.00016 0.00015 0.00000 nan nan
    4 ps_calc_05 0.00012 0.00029 0.00000 nan nan
    5 ps_calc_06 0.00000 0.00095 0.02688 nan nan
    6 ps_calc_07 0.00001 0.00051 0.01344 nan nan
    7 ps_calc_08 0.00000 0.00041 0.01344 nan nan
    8 ps_calc_09 -0.00008 0.00030 0.00000 nan nan
    9 ps_calc_10 0.00345 0.00046 0.02016 1.00001 0.00875
    10 ps_calc_11 -0.00000 0.00139 0.02352 nan nan
    11 ps_calc_12 -0.00021 0.00058 0.00672 nan nan
    12 ps_calc_13 -0.00011 0.00025 0.02016 nan nan
    13 ps_calc_14 0.00291 0.00032 0.00336 1.00000 0.00422
    14 ps_calc_15_bin -0.00086 0.00001 0.00000 nan nan
    15 ps_calc_16_bin 0.00161 0.00001 0.00000 nan nan
    16 ps_calc_17_bin -0.00045 0.00000 0.00672 nan nan
    17 ps_calc_18_bin 0.00133 0.00001 0.01344 nan nan
    18 ps_calc_19_bin -0.00444 0.00009 0.00000 nan nan
    19 ps_calc_20_bin -0.00206 0.00003 0.00000 nan nan
    20 ps_car_01_cat -0.00032 0.03670 0.23521 nan nan
    21 ps_car_02_cat -0.06323 0.02523 -0.00336 nan nan
    22 ps_car_03_cat 0.06276 0.01027 0.11425 nan nan
    23 ps_car_04_cat 0.00791 0.03496 0.08400 nan nan
    24 ps_car_05_cat 0.03309 0.00002 0.01008 nan nan
    25 ps_car_06_cat 0.00096 0.03382 0.02352 nan nan
    26 ps_car_07_cat -0.04448 0.00958 0.14449 nan nan
    27 ps_car_08_cat -0.04057 0.01088 0.00336 nan nan
    28 ps_car_09_cat 0.00685 0.01679 0.01680 nan nan
    29 ps_car_10_cat 0.00024 0.00003 0.00000 nan nan
    30 ps_car_11 -0.00440 0.01275 0.00000 nan nan
    31 ps_car_11_cat 0.02483 0.00639 0.09408 1.00395 0.03515
    32 ps_car_12 0.11031 0.03995 0.05712 2.21219 0.08375
    33 ps_car_13 0.15309 0.07207 0.10080 1.91331 0.11525
    34 ps_car_14 0.02395 0.01879 0.02688 1.41419 0.06123
    35 ps_car_15 0.00060 0.03006 0.01008 nan nan
    36 ps_ind_01 0.00414 0.01177 0.00336 nan nan
    37 ps_ind_02_cat 0.00447 0.00108 0.23521 nan nan
    38 ps_ind_03 -0.00178 0.02880 0.06048 nan nan
    39 ps_ind_04_cat 0.02607 0.00291 0.36962 nan nan
    40 ps_ind_05_cat 0.00870 0.03000 0.33265 nan nan
    41 ps_ind_06_bin -0.08869 0.03459 0.06720 nan nan
    42 ps_ind_07_bin 0.07979 0.03082 0.08736 nan nan
    43 ps_ind_08_bin 0.02597 0.00466 0.05040 nan nan
    44 ps_ind_09_bin -0.01708 0.00200 0.02352 nan nan
    45 ps_ind_10_bin 0.00019 0.00008 0.00000 nan nan
    46 ps_ind_11_bin 0.00044 0.00010 0.00000 nan nan
    47 ps_ind_12_bin 0.00403 0.00147 0.00000 nan nan
    48 ps_ind_13_bin 0.00040 0.00015 0.00000 nan nan
    49 ps_ind_14 0.00103 0.00146 0.00000 nan nan
    50 ps_ind_15 -0.00074 0.01612 0.04032 nan nan
    51 ps_ind_16_bin -0.07018 0.02113 0.08400 nan nan
    52 ps_ind_17_bin 0.06450 0.03289 0.11761 nan nan
    53 ps_ind_18_bin 0.00876 0.00058 0.00000 nan nan
    54 ps_reg_01 -0.00032 0.02400 0.05040 nan nan
    55 ps_reg_02 0.00063 0.04186 0.04032 nan nan
    56 ps_reg_03 0.09806 0.03917 0.04368 1.05712 0.08695
    In [27]:
    # Selección final - métricas agregadas y preparación para discretización:
    
    final_df_reduced = final_df.copy()
    final_df_reduced['Sum Gini + IV'] = final_df_reduced[['Gini', 'Information Value (IV)']].abs().sum(axis=1)
    final_df_reduced['Sum Gini + IV + Perm'] = final_df_reduced[['Gini', 'Information Value (IV)', 'Permutation Importance']].abs().sum(axis=1)
    
    # Min/Max para comparabildiad a efectos de tabla visualizada:
    final_df_reduced['Sum Gini + IV Norm'] = (final_df_reduced['Sum Gini + IV'] - final_df_reduced['Sum Gini + IV'].min()) / \
                                             (final_df_reduced['Sum Gini + IV'].max() - final_df_reduced['Sum Gini + IV'].min())
    
    final_df_reduced['Sum Gini + IV + Perm Norm'] = (final_df_reduced['Sum Gini + IV + Perm'] - final_df_reduced['Sum Gini + IV + Perm'].min()) / \
                                                    (final_df_reduced['Sum Gini + IV + Perm'].max() - final_df_reduced['Sum Gini + IV + Perm'].min())
    
    # Visualización:
    columns_to_display = ['Feature', 'Gini', 'Information Value (IV)', 'Permutation Importance', 'Sum Gini + IV', 'Sum Gini + IV + Perm']
    final_df_filtered = final_df_reduced[columns_to_display]
    final_df_filtered = final_df_filtered.sort_values(by=['Sum Gini + IV', 'Sum Gini + IV + Perm'], ascending=False)
    
    cmap = sns.light_palette("red", as_cmap=True)
    styled_df_filtered = final_df_filtered.style.format(precision=5) \
        .background_gradient(cmap=cmap, subset=['Sum Gini + IV', 'Sum Gini + IV + Perm'])
    
    styled_df_filtered
    
    Out[27]:
      Feature Gini Information Value (IV) Permutation Importance Sum Gini + IV Sum Gini + IV + Perm
    33 ps_car_13 0.15309 0.07207 0.10080 0.22516 0.32596
    32 ps_car_12 0.11031 0.03995 0.05712 0.15026 0.20738
    56 ps_reg_03 0.09806 0.03917 0.04368 0.13723 0.18091
    41 ps_ind_06_bin -0.08869 0.03459 0.06720 0.12328 0.19048
    42 ps_ind_07_bin 0.07979 0.03082 0.08736 0.11061 0.19797
    52 ps_ind_17_bin 0.06450 0.03289 0.11761 0.09739 0.21500
    51 ps_ind_16_bin -0.07018 0.02113 0.08400 0.09131 0.17531
    21 ps_car_02_cat -0.06323 0.02523 -0.00336 0.08846 0.09182
    22 ps_car_03_cat 0.06276 0.01027 0.11425 0.07303 0.18728
    26 ps_car_07_cat -0.04448 0.00958 0.14449 0.05406 0.19855
    27 ps_car_08_cat -0.04057 0.01088 0.00336 0.05145 0.05481
    23 ps_car_04_cat 0.00791 0.03496 0.08400 0.04287 0.12687
    34 ps_car_14 0.02395 0.01879 0.02688 0.04274 0.06962
    55 ps_reg_02 0.00063 0.04186 0.04032 0.04249 0.08281
    40 ps_ind_05_cat 0.00870 0.03000 0.33265 0.03870 0.37135
    20 ps_car_01_cat -0.00032 0.03670 0.23521 0.03702 0.27223
    25 ps_car_06_cat 0.00096 0.03382 0.02352 0.03478 0.05830
    24 ps_car_05_cat 0.03309 0.00002 0.01008 0.03311 0.04319
    31 ps_car_11_cat 0.02483 0.00639 0.09408 0.03122 0.12530
    35 ps_car_15 0.00060 0.03006 0.01008 0.03066 0.04074
    43 ps_ind_08_bin 0.02597 0.00466 0.05040 0.03063 0.08103
    38 ps_ind_03 -0.00178 0.02880 0.06048 0.03058 0.09106
    39 ps_ind_04_cat 0.02607 0.00291 0.36962 0.02898 0.39860
    54 ps_reg_01 -0.00032 0.02400 0.05040 0.02432 0.07472
    28 ps_car_09_cat 0.00685 0.01679 0.01680 0.02364 0.04044
    44 ps_ind_09_bin -0.01708 0.00200 0.02352 0.01908 0.04260
    30 ps_car_11 -0.00440 0.01275 0.00000 0.01715 0.01715
    50 ps_ind_15 -0.00074 0.01612 0.04032 0.01686 0.05718
    36 ps_ind_01 0.00414 0.01177 0.00336 0.01591 0.01927
    53 ps_ind_18_bin 0.00876 0.00058 0.00000 0.00934 0.00934
    37 ps_ind_02_cat 0.00447 0.00108 0.23521 0.00555 0.24076
    47 ps_ind_12_bin 0.00403 0.00147 0.00000 0.00550 0.00550
    18 ps_calc_19_bin -0.00444 0.00009 0.00000 0.00453 0.00453
    9 ps_calc_10 0.00345 0.00046 0.02016 0.00391 0.02407
    13 ps_calc_14 0.00291 0.00032 0.00336 0.00323 0.00659
    49 ps_ind_14 0.00103 0.00146 0.00000 0.00249 0.00249
    19 ps_calc_20_bin -0.00206 0.00003 0.00000 0.00209 0.00209
    15 ps_calc_16_bin 0.00161 0.00001 0.00000 0.00162 0.00162
    10 ps_calc_11 -0.00000 0.00139 0.02352 0.00139 0.02491
    17 ps_calc_18_bin 0.00133 0.00001 0.01344 0.00134 0.01478
    1 ps_calc_02 0.00047 0.00049 0.01344 0.00096 0.01440
    5 ps_calc_06 0.00000 0.00095 0.02688 0.00095 0.02783
    14 ps_calc_15_bin -0.00086 0.00001 0.00000 0.00087 0.00087
    2 ps_calc_03 0.00044 0.00036 0.01008 0.00080 0.01088
    11 ps_calc_12 -0.00021 0.00058 0.00672 0.00079 0.00751
    0 ps_calc_01 0.00010 0.00068 0.00672 0.00078 0.00750
    48 ps_ind_13_bin 0.00040 0.00015 0.00000 0.00055 0.00055
    46 ps_ind_11_bin 0.00044 0.00010 0.00000 0.00054 0.00054
    6 ps_calc_07 0.00001 0.00051 0.01344 0.00052 0.01396
    16 ps_calc_17_bin -0.00045 0.00000 0.00672 0.00045 0.00717
    7 ps_calc_08 0.00000 0.00041 0.01344 0.00041 0.01385
    4 ps_calc_05 0.00012 0.00029 0.00000 0.00041 0.00041
    8 ps_calc_09 -0.00008 0.00030 0.00000 0.00038 0.00038
    12 ps_calc_13 -0.00011 0.00025 0.02016 0.00036 0.02052
    3 ps_calc_04 0.00016 0.00015 0.00000 0.00031 0.00031
    29 ps_car_10_cat 0.00024 0.00003 0.00000 0.00027 0.00027
    45 ps_ind_10_bin 0.00019 0.00008 0.00000 0.00027 0.00027
    In [28]:
    # Observo relación entre las dos métricas agregadas generadas:
    
    plt.figure(figsize=(20, 6))
    sns.regplot(
        x=final_df_reduced['Sum Gini + IV'], 
        y=final_df_reduced['Sum Gini + IV + Perm'], 
        scatter_kws={"alpha":1}, 
        line_kws={"color": "red"}  
    )
    
    plt.title("Relación entre 'Sum Gini + IV' y 'Sum Gini + IV + Perm'")
    plt.xlabel("Sum Gini + IV")
    plt.ylabel("Sum Gini + IV + Perm")
    plt.show()
    
    No description has been provided for this image

    Criterio final: Dicotomización óptima del ranking mediante el Método de Otsu

    El método de Otsu es una técnica de "umbralización automática" que permite dividir una variable continua en dos grupos al determinar un umbral óptimo que permite dividir la distribución en 2. Se basa en principios estadísticos sólidos de minimización de varianza intra-clase o, equivalentemente, maximización de varianza inter-clase. Si bien su uso original fue previsto en Computing Vison, en el ámbito de la visión por computador, este método ha demostrado ser igualmente útil en el análisis de datos tabulares, incluyendo selección de variables.


    Fundamento matemático detrás del método:

    Dado un conjunto unidimensional de datos \( X = \{x_1, x_2, ..., x_N\} \), representado mediante un histograma de probabilidad, el objetivo es encontrar un umbral \( t^* \) que:

    • Minimice la varianza intra-clase \( \sigma_w^2(t) \)
    • O, equivalentemente, maximice la varianza inter-clase \( \sigma_b^2(t) \)

    1. Probabilidades de clase acumuladas

    P₀(t) = Σ₀ᵗ pᵢ, P₁(t) = Σₜ₊₁ᴸ₋₁ pᵢ
    pᵢ = hᵢ / N
    

    donde hᵢ es la frecuencia del bin i, y N el número total de observaciones.

    2. Medias de clase y media total

    μ₀(t) = Σ₀ᵗ i·pᵢ / P₀(t), μ₁(t) = Σₜ₊₁ᴸ₋₁ i·pᵢ / P₁(t)
    μ_T = Σ₀ᴸ₋₁ i·pᵢ
    

    3. Varianza intra-clase (minimización)

    σ_w²(t) = P₀(t)·σ₀²(t) + P₁(t)·σ₁²(t)
    

    El umbral óptimo se define como:

    t* = argminₜ σ_w²(t)
    

    4. Varianza inter-clase (maximización)

    σ_b²(t) = P₀(t)·P₁(t)·(μ₀(t) - μ₁(t))²
    t* = argmaxₜ σ_b²(t)
    

    Ambas formulaciones son equivalentes ya que:

    σ_T² = σ_w² + σ_b²
    

    Interpretación y aplicación práctica

    El método de Otsu actúa como un algoritmo de clustering binario unidimensional, encontrando un punto de corte natural en la distribución de la variable. Su utilidad se extiende más allá del procesamiento de imágenes:

    • Segmentación de imágenes: umbralización de pixeles
    • Selección de variables: dicotomización de scores continuos como Gini + IV en nuestro caso particular
    • Detección de anomalías: separación entre comportamientos normales y anómalos

    Justificación de uso en este análisis

    En este trabajo, el método de Otsu se emplea para determinar un punto de corte objetivo sobre la variable agregada Gini + IV. Esta variable combina la capacidad discriminativa con la estabilidad informativa de cada predictor, y su distribución presenta un perfil adecuado para ser dicotomizada de forma no arbitraria.

    Frente a métodos heurísticos o basados en cuantiles fijos, Otsu ofrece una solución optimizada y reproducible para separar automáticamente el conjunto de variables entre "relevantes" y "no relevantes", asegurando una segmentación basada en la estructura estadística interna de los datos.

    In [29]:
    # OTSU:
    
    # Winsorization para mitigar outliers;
    values = final_df_filtered["Sum Gini + IV"].dropna() 
    values_trimmed = values[values < np.percentile(values, 95)].to_numpy()  
    
    # Calcular el umbral de Otsu desde threshold_otsu:
    otsu_threshold = threshold_otsu(values_trimmed)
    print(f"Umbral de Otsu por threshold_otsu desde Skimage: {otsu_threshold}")
    
    
    # Cálculo manual de Otsu en base a definición funcional (ratificación):
    # ---------------------------------------------------------------------
    
    # Binarizo en 20 bins (esto puede llevar a ligeras diferencias):
    hist, bin_edges = np.histogram(values_trimmed, bins=20)
    bin_mids = (bin_edges[:-1] + bin_edges[1:]) / 2
    
    # Prob de cada clase:
    w1 = np.cumsum(hist)
    w2 = np.cumsum(hist[::-1])[::-1]
    
    # mediana acumulada:
    media1 = np.cumsum(hist * bin_mids) / w1
    media2 = (np.cumsum((hist * bin_mids)[::-1]) / w2[::-1])[::-1]
    
    # varianza inter-clase
    var_inter = (w1[:-1] * w2[1:] * (media1[:-1] - media2[1:])**2)
    
    # índice con máxima varianza inter-clase:
    id_max_var = np.argmax(var_inter)
    opt_thr = bin_mids[id_max_var]
    
    print(f"Umbral de Otsu cuantificado manualmente: {opt_thr}")
    
    # Visualización gráfica:
    color =(0,0.5,0.7,0.2)
    plt.figure(figsize=(20, 10))
    plt.hist(values_trimmed, bins=20, color=color, alpha=0.2, label="Distribución de Sum Gini + IV")
    plt.axvline(opt_thr, color="red", linestyle="--", linewidth=2, label=f"Umbral óptimo: {opt_thr:.5f}")
    plt.xlabel("Sum Gini + IV")
    plt.ylabel("Frecuencia")
    plt.title("Determinación del Punto de Corte Óptimo")
    plt.legend()
    plt.grid(axis="y", linestyle="--", alpha=0.6)
    
    plt.show()
    
    Umbral de Otsu por threshold_otsu desde Skimage: 0.042794941406249995
    Umbral de Otsu cuantificado manualmente: 0.04024825
    
    No description has been provided for this image
    In [30]:
    # Visualización:
    
    df = pd.DataFrame(final_df_filtered)
    
    color_gini = "blue"
    color_iv = "green"
    color_perm = "purple"
    color_bar = (0, 0.5, 0.7, 0.2)
    
    plt.figure(figsize=(20, 8))
    plt.plot(df["Feature"], abs(df["Gini"]), label="Gini", marker="o", color=color_gini, linestyle="-", linewidth=2)
    plt.plot(df["Feature"], abs(df["Information Value (IV)"]), label="Information Value (IV)", marker="s", color=color_iv, linestyle="--", linewidth=2)
    plt.plot(df["Feature"], abs(df["Permutation Importance"]), label="Permutation Importance", marker="^", color=color_perm, linestyle="-.", linewidth=2)
    
    plt.bar(df["Feature"], df["Sum Gini + IV"], label="Sum Gini + IV", color=color_bar)
    plt.axhline(y=opt_thr, color='red', linestyle='--', linewidth=2, label=f"Umbral Otsu: {opt_thr:.5f}")
    plt.xlabel("Feature")
    plt.ylabel("Value")
    plt.title("Feature Importance Metrics")
    plt.xticks(rotation=90)
    plt.legend()
    plt.grid(True)
    
    plt.show()
    
    No description has been provided for this image

    Conjunto de datos final: Short List

    In [31]:
    df_final_train = final_df_filtered[final_df_filtered['Sum Gini + IV'] >= opt_thr]
    df_final_train
    
    Out[31]:
    Feature Gini Information Value (IV) Permutation Importance Sum Gini + IV Sum Gini + IV + Perm
    33 ps_car_13 0.15309 0.07207 0.10080 0.22516 0.32596
    32 ps_car_12 0.11031 0.03995 0.05712 0.15026 0.20738
    56 ps_reg_03 0.09806 0.03917 0.04368 0.13723 0.18091
    41 ps_ind_06_bin -0.08869 0.03459 0.06720 0.12328 0.19048
    42 ps_ind_07_bin 0.07979 0.03082 0.08736 0.11061 0.19797
    52 ps_ind_17_bin 0.06450 0.03289 0.11761 0.09739 0.21500
    51 ps_ind_16_bin -0.07018 0.02113 0.08400 0.09131 0.17531
    21 ps_car_02_cat -0.06323 0.02523 -0.00336 0.08846 0.09182
    22 ps_car_03_cat 0.06276 0.01027 0.11425 0.07303 0.18728
    26 ps_car_07_cat -0.04448 0.00958 0.14449 0.05406 0.19855
    27 ps_car_08_cat -0.04057 0.01088 0.00336 0.05145 0.05481
    23 ps_car_04_cat 0.00791 0.03496 0.08400 0.04287 0.12687
    34 ps_car_14 0.02395 0.01879 0.02688 0.04274 0.06962
    55 ps_reg_02 0.00063 0.04186 0.04032 0.04249 0.08281
    In [32]:
    # Extracción de la lista de valores únicos en la columna "Features" y añadir target e id, a efectos de identificación:
    features_list = ["id", "target"] + df_final_train["Feature"].unique().tolist()
    
    # Dataset final para planteamiento analítico - Modelización de target:
    train_filtered = train[features_list]
    
    In [33]:
    train_filtered.info()
    
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 595212 entries, 0 to 595211
    Data columns (total 16 columns):
     #   Column         Non-Null Count   Dtype  
    ---  ------         --------------   -----  
     0   id             595212 non-null  int64  
     1   target         595212 non-null  int64  
     2   ps_car_13      595212 non-null  float64
     3   ps_car_12      595212 non-null  float64
     4   ps_reg_03      595212 non-null  float64
     5   ps_ind_06_bin  595212 non-null  int64  
     6   ps_ind_07_bin  595212 non-null  int64  
     7   ps_ind_17_bin  595212 non-null  int64  
     8   ps_ind_16_bin  595212 non-null  int64  
     9   ps_car_02_cat  595212 non-null  int64  
     10  ps_car_03_cat  595212 non-null  int64  
     11  ps_car_07_cat  595212 non-null  int64  
     12  ps_car_08_cat  595212 non-null  int64  
     13  ps_car_04_cat  595212 non-null  int64  
     14  ps_car_14      595212 non-null  float64
     15  ps_reg_02      595212 non-null  float64
    dtypes: float64(5), int64(11)
    memory usage: 72.7 MB
    
    In [34]:
    train_filtered.head()
    
    Out[34]:
    id target ps_car_13 ps_car_12 ps_reg_03 ps_ind_06_bin ps_ind_07_bin ps_ind_17_bin ps_ind_16_bin ps_car_02_cat ps_car_03_cat ps_car_07_cat ps_car_08_cat ps_car_04_cat ps_car_14 ps_reg_02
    0 7 0 0.883679 0.404717 -0.464869 0 1 1 0 1 -1 1 0 0 -0.123259 0.2
    1 9 0 0.618817 -0.868016 -0.310186 0 0 0 0 1 -1 1 1 0 0.454249 0.4
    2 13 0 0.641586 -0.868016 0.010037 0 0 0 1 1 -1 1 1 0 -0.764710 0.0
    3 16 0 0.542949 -0.191865 -1.107059 1 0 0 1 1 0 1 1 0 -1.862552 0.2
    4 17 0 0.565832 -1.831224 0.332574 1 0 0 1 1 -1 1 1 0 -0.316330 0.6
    In [35]:
    # Tipología del subconjunto siguiendo mismo criterio que sobre el conjunto total:
    target='target'
    binary_vars, categorical_vars, continuous_vars = classify_variables(train_filtered, target)
    
    print(f"Variables binarias: {binary_vars}\n")
    print(f"Variables categóricas: {categorical_vars}\n")
    print(f"Variables continuas: {continuous_vars}\n")
    
    Variables binarias: ['ps_ind_06_bin', 'ps_ind_07_bin', 'ps_ind_17_bin', 'ps_ind_16_bin', 'ps_car_08_cat']
    
    Variables categóricas: ['ps_car_02_cat', 'ps_car_03_cat', 'ps_car_07_cat', 'ps_car_04_cat', 'ps_reg_02']
    
    Variables continuas: ['ps_car_13', 'ps_car_12', 'ps_reg_03', 'ps_car_14']
    
    
    In [36]:
    # Viz de variables, previa antrada a modelo:
    
    fig, ax = plt.subplots(figsize=(16, 8))
    for col in ['ps_reg_03', 'ps_car_12', 'ps_car_14', 'ps_car_13']:
        sns.kdeplot(train_filtered[col].dropna(), label=col, fill=True, alpha=0.4)
    
    ax.set_title("Distribución de Variables Previas a Entrada al Modelo", fontsize=14)
    ax.set_xlabel("Valor")
    ax.set_ylabel("Densidad")
    ax.legend()
    
    plt.show()
    
    No description has been provided for this image
    In [37]:
    # Normalizo con el Imputer que ya había generado para normalizar las otras cuant:
    train_filtered[['ps_car_13']] = normal_dist.fit_transform(train_filtered[['ps_car_13']])
    
    # Visualizo de nuevo:
    fig, ax = plt.subplots(figsize=(16, 8))
    for col in ['ps_reg_03', 'ps_car_12', 'ps_car_14', 'ps_car_13']:
        sns.kdeplot(train_filtered[col].dropna(), label=col, fill=True, alpha=0.4)
    
    ax.set_title("Distribución de Variables Previas a Entrada al Modelo", fontsize=14)
    ax.set_xlabel("Valor")
    ax.set_ylabel("Densidad")
    ax.legend()
    
    plt.show()
    
    C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\800433536.py:2: SettingWithCopyWarning:
    
    
    A value is trying to be set on a copy of a slice from a DataFrame.
    Try using .loc[row_indexer,col_indexer] = value instead
    
    See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
    
    
    No description has been provided for this image

    4. Modelización

    Tal y como hemos seguido durante todo el caso de uso, a lo largo de la línea lógica de desarrollo y explotación del conocimiento del dataset, el planteamiento de desarrollo de una solución cuantitativa también requiere de especial atención a causa del desbalanceo natural del conjunto de datos de Porto Seguro.

    Para hacer frente a la modelización de un conjunto de datos preprocesado, pero desbalanceado, es importante tener en cuenta su impacto tanto en la propia modelización (desde la solución cuantitativa adoptada, hasta la hiperparametría para ajustar el problema), y sobre todo, en las métricas de error que realmente nos den explicabilidad acerca de la bondad de ajuste de nuestro clasificador.

    A continuación, se va a proceder con:

    • [1] Una explicación de cómo se debe interpretar el resultado del clasificador en un caso desbalanceado.
    • [2] Un detalle de las soluciones cuantitativas que se van a implementar de cara a su futuro contraste sobre el caso de uso, y justificadas por la situación actual del estado del arte en materia.

    Métricas de error para Datos Desbalanceados sobre target binario (0–1 de riesgo de siniestralidad)

    El procedimiento nominal en la evaluación del error de un planteamiento de clasificación binaria, como es nuestro caso de uso, pasa por la evaluación lógica de la matriz de confusión. Esta matriz nos permite obtener mucha información acerca del ajuste de nuestro clasificador, en función de cómo este se haya desempeñado (predicción), en contraste con la realidad (actual/real).

    Asimismo, es necesario tener en cuenta que la matriz de confusión es simplemente un snapshot del confidence/cut-off óptimo que cada solución cuantitativa ha adoptado. Por esa razón, además de esta matriz, se articulan métricas que consideran la totalidad del rango de confianza (0–1), en lo relativo a su clasificación.

    Estas métricas —como ROC-AUC o PR-AUC— nos proporcionan una imagen completa sobre el desempeño del modelo, a partir del análisis de la distribución de métricas extraídas de la matriz de confusión, pero observadas a lo largo de todos los posibles valores de corte.

    A continuación, se especifica cómo debe interpretarse el error de un clasificador binario como el que tenemos en este problema, para un caso de uso desbalanceado como es el riesgo de siniestralidad.

    Idiosincrasia de la Matriz de Confusión vs. Métricas AUC como elemento clave

    Uno de los aspectos más críticos —y frecuentemente que suelen ser malinterpretados— en la evaluación de modelos de clasificación binaria tanto sobre datasets desbalanceados como a nivel más genérico, es el carácter **puntual y condicional** de la matriz de confusión frente al carácter **global y marginal** de métricas como ROC-AUC y PR-AUC.

    Como se cita anteriormente, la matriz de confusión representa una única "snapshot" del rendimiento del modelo para un umbral de decisión (cut-off) específico, normalmente atribuido como el óptimo, ya que es normalmente cuando se apsa a mostrar la matriz de confusión. Dado un score de probabilidad generado por el modelo para cada observación, se fija un umbral (por ejemplo, 0.5), y a partir de este se decide si una observación pertenece a la clase positiva o negativa. Es decir:

    ŷi = 1 si P(y=1 | xi) ≥ τ
    ŷi = 0 si P(y=1 | xi) < τ

    Este valor de corte define completamente la matriz de confusión (TP, FP, TN, FN) y por tanto todas las métricas asociadas a ella como precisión, recall, especificidad, F1-score, etc. Sin embargo, esta visión es parcial, ya que solo representa el rendimiento en ese único punto de decisión, y no captura el comportamiento del modelo ante distintas decisiones en función del umbral de corte.

    En términos más estadísticos, la matriz de confusión es **condicional al cut-off elegido**, mientras que ROC-AUC y PR-AUC son **incondicionales**, ya que consideran todo el soporte del score de clasificación.

    Una vez tenemos claro esto:

    Las métricas ROC-AUC y PR-AUC evalúan la capacidad discriminante del modelo sobre todos los posibles umbrales τ ∈ [0,1]. En lugar de tomar una decisión de clase directa, estas métricas utilizan los scores continuos generados por el modelo para construir curvas que integran la variación del rendimiento:

    • ROC-AUC: Área bajo la curva ROC (TPR vs. FPR) al variar el umbral τ.
    • PR-AUC: Área bajo la curva Precision vs. Recall al variar el umbral τ.

    Estas métricas no requieren fijar ningún umbral, por lo que ofrecen una evaluación agregada y robusta de la capacidad predictiva del modelo, independientemente del corte elegido.

    Interpretación clave: Mientras que una matriz de confusión responde a “¿qué tal clasifica el modelo con un τ fijo?”, el AUC responde a “¿cuánto mejor es capaz el modelo de clasificar mejor que una predicción aleatoria, considerando todos los posibles τ?”.

    Integración con los resultados soluciones predictivas adoptadas:

    Para tener una visión holística y contrastar soluciones analíticas como las que se propondran en este apartado de forma robusta, las métricas AUC (tanto roc_auc_score como average_precision_score) deben ser el criterio principal. A partir de estas, se puede luego descender al detalle puntual de una matriz de confusión para interpretar casos concretos y calcular métricas más aplicables a negocio (por ejemplo, tasa de siniestros correctamente identificados a un coste aceptable de falsos positivos).

    Precision y Recall como métricas clave en un clasificador desbalanceado

    Sobre un caso de detección de siniestros como el nuestro( Aprox. un 4% clase positiva)—, no todas las métricas tienen el mismo poder explicativo. En particular, las métricas que ponderan por igual ambas clases (como accuracy) pueden llevar a interpretaciones erróneas sobre el correcto rendimiento que realmente está realizando el modelo.

    Por ello, se hace necesario desplazar el foco hacia métricas que reflejen con mayor fidelidad la capacidad del modelo de identificar correctamente la clase minoritaria (siniestros), sin que la clase mayoritaria (no siniestros) distorsione la evaluación.


    1. En la matriz de confusión: el sesgo de la clase mayoritaria

    Supongamos que un modelo predice todo como clase negativa. En un dataset con 96% de clase 0 (no siniestro), se obtendría un accuracy del 96% sin haber detectado ningún siniestro (TP = 0), lo cual es completamente inútil desde el punto de vista del negocio.

    En estas situaciones, precision y recall aplicadas sobre la clase positiva son métricas fundamentales:

    • Precision: ¿Qué proporción de las predicciones positivas son realmente siniestros?
    • Recall (TPR): ¿Qué proporción de siniestros reales ha sido detectada?

    El compromiso entre ambas se sintetiza en el F1-score, que penaliza fuertemente cuando una de las dos métricas es baja


    2. En la curva AUC: PR-AUC vs. ROC-AUC

    La métrica ROC-AUC, pondera por igual la tasa de verdaderos positivos (TPR) y la tasa de falsos positivos (FPR). Esta simetría puede ser controvertida cuando la clase positiva es escasa, ya que incluso una mejora significativa en la detección de siniestros puede no reflejarse adecuadamente en el valor del ROC-AUC. En ese contexto, aunque ROC-AUC nos proporciona de manera generalizada la separación de ambas clases en el clasificador (poder discriminante), y se establece como principal métrica objetivo dado el problema del clasificador binario, la PR-AUC (área bajo la curva Precision-Recall) en nuestro caso también nos aporta información relevante ya que:

    • Se centra exclusivamente en la clase positiva (siniestros).
    • Ignora la clase mayoritaria, evitando el sesgo del desequilibrio.
    • Es más sensible a mejoras reales en la detección de eventos poco frecuentes.

    Consideración operativa

    De este modo, los modelos se contrastarán según como diferencien las clases en el planteamiento del clasificador ante el evento binario de siniesto/no siniestro. Esto pasa por visualizar el área bajo la curva ROC, la cual es linealmente extrapolable al íncide de Gini, basado en la curva de Lorentz (y visualizado más arriba), siendo esta la métrica usada en los planteamientos analíticos en estado del arte en materia de cusntificación de riesgos bajo enevsnto binarios. No obstante, dada la naturaleza de estos eventos, visualizaremos un segundo eje, a nivel de métricas generales del modelo, como es el valor PR-AUC, como eje secundario en nuestro problema. Finalmente, un "mejor modelo", podrá designarse en base a la observación de ambas métricas, pues nos dotan de información global sobre como ha performado cada modelo. Sedrá en este punto dodne, tras seleccionar un mejor modelo en base a su poder discriminante, se pase a visualizar, "su interior". Hablamos de la matriz de confusión, para los distintos cut-off, pues será esencial entender como el clasificador separa a los eventos en función del umbral, y ser capaces de justificar qué umbral o rango de umbrales nos podría interesar para nuestro problema específico de detección del riesgo asegurador, ya que es justo en ese momento donde nos separaremos más de una solución "técnica" y pasaremos a tomar decisiones en función de nuestros intereses de negocio. Debemos tener en cuenta como el poder diferenciador/discriminante que nos proporciona ROC-AUC/PR-AUC, es invariante en función del cutt-off, ya que este como bien hemos comentado se establece sobre todo el espectro posible de esto. Es por eso, que cuando seleccionemos el mejor modelo, a nivel técnico ya sabemos como este performa/distingue las clases, y únicamente tenemos capacidad sobre como adaptarlo a nuestro planteamiento específico desde un punto de vista más funcional, de negocio. Esto apsa por decirir un umbral óptimo y por calibrar el modelo. Este último punto, si bien se verá en detalle, es otra herramienta que nos permite pasar de un evento binario (siniestro/no siniestro) y de la capacidad de tener un modelo que diferencie este evento, a un planteamiento basado en probabilidades de ocurrencia del evento. Este paso es esencial, ya que pasamos de la detección del siniestro, a la detección del riesgo siniestral, siendo este el foco específico de nuestro caso de uso.

    Modelos e Hiperparámetros

    Un punto crítico cuando se tiene a disposición un conjunto de datos tratado/analizado previamente con el que se quiere obtener valor, es la selección de soluciones analíticas que se ajusten a nuestro caso de uso. Es precisamente en este punto donde entra de manera impactada el Estado del Arte en Materia de Modelización del riesgo (no solo de siniestralidad, sino extensible a casos como el riesgo de crédito).

    El planteamiento es básico, y sigue una lógica irrefutable en la consideración de los algoritmos que se aplican en el presente caso de uso:

    • [1] Solución analítica adaptada por la industria durante los últimos 30 años en materia de modelización del riesgo → Regresión logística.
    • [2] Estado del arte actual: adopción de metodologías basadas en árboles de clasificación con Boosting, por su elevada capacidad de explicabilidad con métodos como SHAP, LIME o PDP y su habilidad para modelizar patrones no lineales.
    • [3] Solución analítica innovadora, nunca antes planteada en problemas con carácter regulatorio, basada en redes neuronales (ANNs) y optimizada para datos tabulares.

    A partir de aquí, la exploración de cada solución analítica pasa por entender el rango de hiperparametría que podemos ajustar antes del entrenamiento para adaptar la solución a nuestro caso de uso específico. Es importante resaltar que estos hiperparámetros deben considerar siempre:

    • Consciencia situacional del problema desbalanceado que pretendemos solucionar
    • Regularización

    1️. Regresión Logística (LogisticRegression)

    c
    HiperparámetroExplicaciónRango
    CControl de capacidad/fuerza de regularización (valores bajos = más penalización)0.01 – 10
    class_weightAjuste del peso en clases desbalanceadas"balanced"
    solverAlgoritmo numérico de optimización["lbfgs", "liblinear"]
    max_iterIteraciones máximas en la optimización–
    model.fitSin métricas internas: la evaluación se realiza externamente–

    2️. XGBoost (XGBClassifier)

    HiperparámetroExplicaciónRango
    n_estimatorsNúmero de árboles50 – 500
    max_depthProfundidad máxima3 – 12
    learning_rateTasa de aprendizaje0.005 – 0.3
    scale_pos_weightPeso de la clase positiva según el desbalanceCalculado previamente
    subsampleFracción de muestras por árbol0.6 – 1.0
    colsample_bytreeFracción de variables por árbol0.6 – 1.0
    gammaPenalización de complejidad0 – 5
    reg_alphaRegularización L10 – 1
    reg_lambdaRegularización L20 – 1
    model.fit[eval_set]Conjunto de validación [(X_test, y_test)]–
    model.fit[eval_metric]Métrica a monitorizar–
    model.fit[early_stopping_rounds]Detención anticipada tras 50 iteraciones sin mejora–

    3️. LightGBM (LGBMClassifier)

    HiperparámetroExplicaciónRango
    num_leavesNúmero máximo de hojas10 – 100
    learning_rateTasa de aprendizaje0.005 – 0.3
    n_estimatorsNúmero de árboles50 – 500
    is_unbalanceCompensación automática de clasesTrue
    feature_fractionFracción de variables por iteración0.6 – 1.0
    lambda_l1Regularización L10.0 – 1.0
    lambda_l2Regularización L20.0 – 1.0
    model.fit[eval_set]Conjunto de validación [(X_test, y_test)]–
    model.fit[eval_metric]Métrica de evaluación–
    model.fit[objective]Binary: obligatorio para clasificación binaria–

    4️. TabNet (TabNetClassifier)

    HiperparámetroExplicaciónRango
    n_d, n_aDimensión de decisión y atención8 – 64
    n_stepsPasos de decisión3 – 10
    gammaPenalización por complejidad1.0 – 2.0
    lambda_sparseRegularización L1 para sparsity0.001 – 0.1
    momentumSuavizado en actualizaciones0.02 – 0.98
    model.fit[eval_set]Requiere arrays NumPy [(X_test, y_test)]–
    model.fit[eval_metric]Métrica para monitorizar mejora–
    model.fit[patience]Parada temprana tras N iteraciones sin mejora–
    model.fit[max_epochs]Número máximo de épocas–
    model.fit[batch_size]Tamaño del batch–
    model.fit[virtual_batch_size]Batch virtual para normalización–

    Diferencias clave entre TabNet y soluciones Boosting

    CaracterísticaTabNetXGBoost / LightGBM
    EnfoqueRed neuronal con atenciónÁrboles de boosting
    Selección de variablesAutomáticaManual
    Valores nulosNo requiere imputaciónRequiere imputación
    AprendizajePasos de atención progresivosÁrboles secuenciales
    Coste computacionalAltoModerado
    Tamaño de datasetGrande (miles o millones)Cualquier tamaño

    Tratamiento de los conjuntos de datos:

    Procedimiento de Preparación del Dataset

    1. Separación de Conjuntos


    • Objetivo: Dividir el conjunto de datos en tres partes: entrenamiento, validación y test.

    • Procedimiento: Se eliminan la columna target y la columna id para definir las variables predictoras (X) y la variable objetivo (y).

    Se utiliza train_test_split para realizar una primera separación en dos partes: un conjunto de entrenamiento/validación (80%) y un conjunto de test (20%), asegurando el balance de clases mediante el parámetro stratify=y.

    Posteriormente, el conjunto de entrenamiento/validación se divide de nuevo en entrenamiento (80% del 80%) y validación (20% del 80%).

    1. Preprocesamiento de Datos


    • Imputación de Valores Faltantes: Se aplica SimpleImputer para reemplazar los valores faltantes en las variables categóricas con un valor constante (por ejemplo, -1).

    • Codificación de Variables Categóricas: Se utiliza OneHotEncoder para transformar las variables categóricas en un formato numérico compatible con los algoritmos de aprendizaje automático.

    • Preparación de Variables Binarias y Continuas:
         – Las variables binarias se mantienen sin transformaciones adicionales.
         – Las variables continuas, que ya están escaladas, se utilizan directamente.

    Todas las transformaciones anteriores se combinan para generar el conjunto final de datos procesados, integrando las variables categóricas codificadas, las binarias originales y las continuas normalizadas.

    1. Verificación de la División y del Balance de Clases


    Se comprueba que la proporción de clases (target = 0/1) se haya mantenido en cada conjunto resultante: entrenamiento, validación y test.

    In [38]:
    X = train_filtered.drop(columns=["target", "id"])  # Quitar target e ID
    y = train_filtered["target"]
    
    # División en train_val y test (80/20) el conjunto total:
    X_train_val, X_test, y_train_val, y_test = train_test_split(X, y, test_size=0.2, random_state=123, stratify=y)
    
    # División del conjunto de entrenamiento en train y validación (80/20 de train_val) sobre este mismo:
    X_train, X_val, y_train, y_val = train_test_split(X_train_val, y_train_val, test_size=0.2, random_state=123, stratify=y_train_val)
    
    # PREPROCESAMIENTO
    # --------------------------------------------------------------------------------
    
    # Imputación de valores en categóricas con -1
    imputer_cat = SimpleImputer(strategy="constant", fill_value=-1)
    X_train[categorical_vars] = imputer_cat.fit_transform(X_train[categorical_vars])
    X_val[categorical_vars] = imputer_cat.transform(X_val[categorical_vars])
    X_test[categorical_vars] = imputer_cat.transform(X_test[categorical_vars])
    
    # One-Hot Encoding para variables categóricas
    encoder = OneHotEncoder(handle_unknown='ignore', sparse_output=False)
    X_train_cat = encoder.fit_transform(X_train[categorical_vars])
    X_val_cat = encoder.transform(X_val[categorical_vars])
    X_test_cat = encoder.transform(X_test[categorical_vars])
    
    # Variables binarias (se quedan igual)
    X_train_bin = X_train[binary_vars].values
    X_val_bin = X_val[binary_vars].values
    X_test_bin = X_test[binary_vars].values
    
    # Variables continuas (ya están escaladas, se dejan igual)
    X_train_quant = X_train[continuous_vars].values
    X_val_quant = X_val[continuous_vars].values
    X_test_quant = X_test[continuous_vars].values
    
    # Concatenación de todas las transformaciones para cada conjunto
    X_train_processed = np.hstack([X_train_cat, X_train_bin, X_train_quant])
    X_val_processed   = np.hstack([X_val_cat, X_val_bin, X_val_quant])
    X_test_processed  = np.hstack([X_test_cat, X_test_bin, X_test_quant])
    
    # Número de registros en cada conjunto
    print("Registros en el conjunto de entrenamiento:", X_train_processed.shape[0])
    print("Registros en el conjunto de validación:", X_val_processed.shape[0])
    print("Registros en el conjunto de test:", X_test_processed.shape[0])
    
    # Observación:
    # Balance de clases en cada conjunto (usando los vectores originales de y)
    print("\nBalance de clases en el conjunto de entrenamiento:")
    print(y_train.value_counts(normalize=True))
    print("\nBalance de clases en el conjunto de validación:")
    print(y_val.value_counts(normalize=True))
    print("\nBalance de clases en el conjunto de test:")
    print(y_test.value_counts(normalize=True))
    
    Registros en el conjunto de entrenamiento: 380935
    Registros en el conjunto de validación: 95234
    Registros en el conjunto de test: 119043
    
    Balance de clases en el conjunto de entrenamiento:
    target
    0    0.963553
    1    0.036447
    Name: proportion, dtype: float64
    
    Balance de clases en el conjunto de validación:
    target
    0    0.963553
    1    0.036447
    Name: proportion, dtype: float64
    
    Balance de clases en el conjunto de test:
    target
    0    0.963551
    1    0.036449
    Name: proportion, dtype: float64
    
    In [39]:
    # Balance de las clasess a predecir por el modelo. XGBoost y LightGBM utilizarán este peso para darle más importancia a los errores en la clase minoritaria.
    scale_pos_weight = y_train.value_counts()[0] / y_train.value_counts()[1]
    print(scale_pos_weight)
    
    26.4369778161913
    

    Diseño de Modelos. Selección de Hiperparámetros con Optuna.

    Una vez tenemos claro el conjunto de datos de entrada a cada modelo en las fases de entrenamiento, validación y test, y tras haber expuesto de forma exhaustiva el espectro de hiperparámetros y sus rangos acotados de valores, es el momento de introducir la selección de hiperparámetros y, en consecuencia, el uso de Optuna.

    El proceso nominal de calibración de un algoritmo pasa por buscar la mejor combinación posible de hiperparámetros para nuestro caso de uso concreto. Este paso, lejos de ser trivial, implica enfrentarse a interacciones no lineales entre los parámetros y a la complejidad inherente que ofrece cada modelo mediante sus grados de libertad (hiperparámetros idiosincráticos).

    Por ello, el paso previo y de mayor relevancia en cualquier flujo de entrenamiento serio es definir de forma consciente y razonada el subconjunto de hiperparámetros a optimizar para cada solución propuesta. La importancia de este proceso es evidente: impacta directamente en la capacidad predictiva y en la generalización del modelo final.

    Un ajuste adecuado nos permite alcanzar una solución robusta y eficiente para el problema, mientras que una mala selección puede derivar en sobreajuste, infraajuste o incluso en una utilización ineficaz tanto de los datos como de los recursos computacionales.

    Antes de abordar en detalle la optimización con Optuna, conviene tener una visión estructurada de las principales estrategias existentes para la selección de hiperparámetros en el contexto actual del aprendizaje automático. A continuación, se presenta una clasificación técnica que distingue los distintos enfoques según su naturaleza, desde métodos clásicos hasta técnicas más sofisticadas basadas en modelos probabilísticos o asignación dinámica de recursos.

    Justificación de la selección de Optuna como técnica de optimización¶

    Categoría Método / Framework Técnica principal Tipo de búsqueda Referencias clave
    Búsqueda clásica Grid Search Búsqueda exhaustiva Determinista Bergstra & Bengio, 2012
    Randomized Search Muestreo aleatorio Estocástica Bergstra & Bengio, 2012
    Optimización bayesiana Bayesian Optimization Modelado con Gaussian Process Adaptativa Snoek et al., 2012
    Hyperopt Tree-structured Parzen Estimator (TPE) Adaptativa Bergstra et al., 2013
    Optuna TPE + pruning (early stopping) Adaptativa + eficiente Akiba et al., 2019
    Asignación de recursos Hyperband Sucesivos descartes con early stopping Basada en rendimiento Li et al., 2017
    BOHB (HpBandSter) Bayesian Opt. + Hyperband Adaptativa + recursos Falkner et al., 2018
    Metaheurísticas evolutivas Genetic Algorithms Evolución biológica simulada Estocástica, heurística DEAP library
    TPOT AutoML basado en programación genética Heurística + AutoML Olson & Moore, 2016

    Si bien existen alternativas muy potentes como BOHB o algoritmos evolutivos, Optuna presenta una combinación especialmente atractiva de eficiencia computacional, flexibilidad y capacidad adaptativa. Gracias a su enfoque basado en TPE, su integración con técnicas de *early stopping* y su diseño modular, se posiciona como una de las soluciones más robustas y modernas para contextos de modelización avanzada con restricciones computacionales. Estas características justifican plenamente su elección como herramienta de optimización en este trabajo.

    In [40]:
    # Definición de regresión logística. Planteamiento clásico:
    def objective_logreg(trial):
        params = {
            "C": trial.suggest_float("C", 0.01, 10, log=True),
            "solver": trial.suggest_categorical("solver", ["lbfgs", "liblinear"]),
            "class_weight": "balanced",
            "max_iter": 500
        }
        model = LogisticRegression(**params)
        model.fit(X_train_processed, y_train)
        y_pred_proba = model.predict_proba(X_val_processed)[:, 1]
        return roc_auc_score(y_val, y_pred_proba)
    
    
    # Definición de XGB:
    def objective_xgb(trial):
        params = {
            "n_estimators": trial.suggest_int("n_estimators", 50, 500),
            "max_depth": trial.suggest_int("max_depth", 3, 12),
            "learning_rate": trial.suggest_float("learning_rate", 0.005, 0.3, log=True),
            "scale_pos_weight": scale_pos_weight,  # Ajuste para el desbalanceo
            "subsample": trial.suggest_float("subsample", 0.6, 1.0),
            "colsample_bytree": trial.suggest_float("colsample_bytree", 0.6, 1.0),
            "gamma": trial.suggest_float("gamma", 0, 5),
            "reg_alpha": trial.suggest_float("reg_alpha", 0.0, 1.0),  # Regularización L1
            "reg_lambda": trial.suggest_float("reg_lambda", 0.0, 1.0)  # Regularización L2
        }
        
        model = XGBClassifier(**params, use_label_encoder=False, eval_metric="auc")
        model.fit(
            X_train_processed, y_train,
            eval_set=[(X_val_processed, y_val)],
            verbose=False
        )
        y_pred_proba = model.predict_proba(X_val_processed)[:, 1]
        return roc_auc_score(y_val, y_pred_proba)
    
    
    # Definición de LigthGBM:
    def objective_lgbm(trial):
        params = {
            "num_leaves": trial.suggest_int("num_leaves", 10, 100),
            "learning_rate": trial.suggest_float("learning_rate", 0.005, 0.3, log=True),
            "n_estimators": trial.suggest_int("n_estimators", 50, 500),
            "is_unbalance": True,  # LightGBM ajusta automáticamente la ponderación de clases
            "feature_fraction": trial.suggest_float("feature_fraction", 0.6, 1.0),
            "lambda_l1": trial.suggest_float("lambda_l1", 0.0, 1.0),  # Regularización L1
            "lambda_l2": trial.suggest_float("lambda_l2", 0.0, 1.0)   # Regularización L2
        }
        
        model = LGBMClassifier(**params, objective="binary")
        model.fit(
            X_train_processed, y_train,
            eval_set=[(X_val_processed, y_val)],
            eval_metric="auc"
        )
        y_pred_proba = model.predict_proba(X_val_processed)[:, 1]
        return roc_auc_score(y_val, y_pred_proba)
    
    
    # Definición de Tabnet:
    def objective_tabnet(trial):
        params = {
            "n_d": trial.suggest_int("n_d", 8, 64),
            "n_a": trial.suggest_int("n_a", 8, 64),
            "n_steps": trial.suggest_int("n_steps", 3, 10),
            "gamma": trial.suggest_float("gamma", 1.0, 2.0),
            "lambda_sparse": trial.suggest_float("lambda_sparse", 0.001, 0.1),
            "momentum": trial.suggest_float("momentum", 0.02, 0.98)
        }
        
        model = TabNetClassifier(**params, verbose=0)
        model.fit(
            X_train_processed, y_train.values,
            eval_set=[(X_val_processed, y_val.values)],
            eval_metric=['auc'],
            patience=50,         
            max_epochs=1000,      
            batch_size=256,  
            virtual_batch_size=64,
            drop_last=False       
        )
        y_pred_proba = model.predict_proba(X_val_processed)[:, 1]
        return roc_auc_score(y_val, y_pred_proba)
    
    In [41]:
    # Versionado y accesibilidad: 
    version = "tfm_rsin_v00"
    version_dir = f"results/{version}"
    
    In [ ]:
    # Creación del directorio:
    os.makedirs(version_dir, exist_ok=True)
    
    # Guardo encoder e imputer para usarlos ex-post (e.g para desplegar entorno productivo):
    with open(f"{version_dir}/encoder_{version}.pkl", "wb") as f:
        pickle.dump(encoder, f)
    
    with open(f"{version_dir}/imputer_cat_{version}.pkl", "wb") as f:
        pickle.dump(imputer_cat, f)
    

    Selección de Hiperparámetros con Optuna:

    In [ ]:
    # EJECUCIÓN DE OPTUNA 
    # --------------------------------------------------------------------------------------------
    
    # Iniciamos servidor Optuna Dashboard en segundo plano
    study_storage = f"sqlite:///{version_dir}/optuna_dashboard_{version}.db"
    
    subprocess.Popen(["optuna-dashboard", study_storage, "--port", "8080"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    time.sleep(3)  # Tiempo de espera de unos segundos para que se inicie el servidor
    
    # Función para mostrar Optuna Dashboard en el notebook:
    def show_optuna_dashboard():
        display(HTML(f'<iframe src="http://localhost:8080" width="100%" height="600px"></iframe>'))
    
    # Estudios:
    n_trials = 10
    
    study_logreg = optuna.create_study(direction="maximize", storage=study_storage, study_name=f"logreg_{version}", load_if_exists=True)
    study_logreg.optimize(objective_logreg, n_trials=n_trials)
    
    study_xgb = optuna.create_study(direction="maximize", storage=study_storage, study_name=f"xgb_{version}", load_if_exists=True)
    study_xgb.optimize(objective_xgb, n_trials=n_trials)
    
    study_lgbm = optuna.create_study(direction="maximize", storage=study_storage, study_name=f"lgbm_{version}", load_if_exists=True)
    study_lgbm.optimize(objective_lgbm, n_trials=n_trials)
    
    study_tabnet = optuna.create_study(direction="maximize", storage=study_storage, study_name=f"tabnet_{version}", load_if_exists=True)
    study_tabnet.optimize(objective_tabnet, n_trials=n_trials)
    
    # Mostramos el dashboard en local cuando se ejecuta por 1a vez:
    show_optuna_dashboard()
    
    In [42]:
    # Viz en web cuando ya está definido el proceso:
    study_storage = f"sqlite:///{version_dir}/optuna_dashboard_{version}.db"
    
    subprocess.Popen(["optuna-dashboard", study_storage, "--port", "8080"], stdout=subprocess.DEVNULL)
    time.sleep(2)
    webbrowser.open("http://localhost:8080")
    
    # El True indica que se ha aperturado correctamente en una ventana del navegador
    
    Out[42]:
    True

    Guardado:

    In [ ]:
    # Guardado de hiperparámetros de cada estudio para posterior entrenamiento:
    
    with open(f"{version_dir}/best_params_logreg_{version}.pkl", "wb") as f:
        pickle.dump(study_logreg.best_params, f)
    
    with open(f"{version_dir}/best_params_xgb_{version}.pkl", "wb") as f:
        pickle.dump(study_xgb.best_params, f)
    
    with open(f"{version_dir}/best_params_lgbm_{version}.pkl", "wb") as f:
        pickle.dump(study_lgbm.best_params, f)
    
    with open(f"{version_dir}/best_params_tabnet_{version}.pkl", "wb") as f:
        pickle.dump(study_tabnet.best_params, f)
    
    In [ ]:
    # Guardado de los estudios completos a parte de solo los mejores hiperparms:
    
    with open(f"{version_dir}/study_logreg_{version}.pkl", "wb") as f:
        pickle.dump(study_logreg, f)
    
    with open(f"{version_dir}/study_xgb_{version}.pkl", "wb") as f:
        pickle.dump(study_xgb, f)
    
    with open(f"{version_dir}/study_lgbm_{version}.pkl", "wb") as f:
        pickle.dump(study_lgbm, f)
    
    with open(f"{version_dir}/study_tabnet_{version}.pkl", "wb") as f:
        pickle.dump(study_tabnet, f)
    

    5. Entrenamiento y Evaluación

    Carga de resultados:

    In [43]:
    # Llamada al resultado de optuna (estudios):
    
    with open(f"{version_dir}/study_logreg_{version}.pkl", "rb") as f:
        study_logreg = pickle.load(f)
    
    with open(f"{version_dir}/study_xgb_{version}.pkl", "rb") as f:
        study_xgb = pickle.load(f)
    
    with open(f"{version_dir}/study_lgbm_{version}.pkl", "rb") as f:
        study_lgbm = pickle.load(f)
    
    with open(f"{version_dir}/study_tabnet_{version}.pkl", "rb") as f:
        study_tabnet = pickle.load(f)
    
    In [44]:
    # Viz del proceso de Optuna, sobre las 10 iteraciones definidas:
    
    def plot_study_history(study, name):
        values = [trial.value for trial in study.trials if trial.value is not None]
        plt.plot(values, label=name)
    
    plt.figure(figsize=(16, 4))
    plot_study_history(study_logreg, "Logistic Regression")
    plot_study_history(study_xgb, "XGBoost")
    plot_study_history(study_lgbm, "LightGBM")
    plot_study_history(study_tabnet, "TabNet")
    plt.xlabel("Iteración")
    plt.ylabel("Score (e.g. AUC)")
    plt.title("Historial de puntuaciones durante la optimización")
    plt.legend()
    plt.grid()
    plt.tight_layout()
    
    plt.show()
    
    No description has been provided for this image
    In [45]:
    # Guardado en df de la información del estudio:
    
    resumen = pd.DataFrame({
        "Modelo": ["Logistic Regression", "XGBoost", "LightGBM", "TabNet"],
        "Mejor Score": [
            study_logreg.best_value,
            study_xgb.best_value,
            study_lgbm.best_value,
            study_tabnet.best_value
        ],
        "Mejor Trial": [
            study_logreg.best_trial.number,
            study_xgb.best_trial.number,
            study_lgbm.best_trial.number,
            study_tabnet.best_trial.number
        ],
        "Nº de Trials": [
            len(study_logreg.trials),
            len(study_xgb.trials),
            len(study_lgbm.trials),
            len(study_tabnet.trials)
        ]
    })
    
    # importancia de hiperparámetros
    importancias = {
        "Logistic Regression": optuna.importance.get_param_importances(study_logreg),
        "XGBoost": optuna.importance.get_param_importances(study_xgb),
        "LightGBM": optuna.importance.get_param_importances(study_lgbm),
        "TabNet": optuna.importance.get_param_importances(study_tabnet),
    }
    
    importances_df = pd.DataFrame.from_dict(importancias, orient="index").T
    
    In [46]:
    display(resumen)
    
    Modelo Mejor Score Mejor Trial Nº de Trials
    0 Logistic Regression 0.612074 2 10
    1 XGBoost 0.613174 8 10
    2 LightGBM 0.614129 3 10
    3 TabNet 0.609591 4 10
    In [47]:
    display(importances_df)
    
    Logistic Regression XGBoost LightGBM TabNet
    C 0.528875 NaN NaN NaN
    solver 0.471125 NaN NaN NaN
    reg_lambda NaN 0.586202 NaN NaN
    learning_rate NaN 0.179487 0.649074 NaN
    subsample NaN 0.074384 NaN NaN
    reg_alpha NaN 0.055256 NaN NaN
    n_estimators NaN 0.051085 0.058087 NaN
    max_depth NaN 0.040157 NaN NaN
    gamma NaN 0.008216 NaN 0.239190
    colsample_bytree NaN 0.005212 NaN NaN
    lambda_l1 NaN NaN 0.164544 NaN
    num_leaves NaN NaN 0.082291 NaN
    lambda_l2 NaN NaN 0.037389 NaN
    feature_fraction NaN NaN 0.008615 NaN
    lambda_sparse NaN NaN NaN 0.302038
    n_steps NaN NaN NaN 0.275329
    n_a NaN NaN NaN 0.066660
    n_d NaN NaN NaN 0.064870
    momentum NaN NaN NaN 0.051913

    Entrenamiento final de Modelos:

    In [48]:
    # Llamo a los resultados para entrenamiento definitivo de modelos:
    
    with open(f"{version_dir}/best_params_logreg_{version}.pkl", "rb") as f:
        params_logreg = pickle.load(f)
    
    with open(f"{version_dir}/best_params_xgb_{version}.pkl", "rb") as f:
        params_xgb = pickle.load(f)
    
    with open(f"{version_dir}/best_params_lgbm_{version}.pkl", "rb") as f:
        params_lgbm = pickle.load(f)
    
    with open(f"{version_dir}/best_params_tabnet_{version}.pkl", "rb") as f:
        params_tabnet = pickle.load(f)
    
    # Diccionarios de guardado de información del entrenamiento:
    modelos = {}
    curvas = {}
    
    # Comprobación de parametría:
    print("LR:", params_logreg)
    print("XGB:", params_xgb)
    print("LGBM:", params_lgbm)
    print("TabNet:", params_tabnet)
    
    LR: {'C': 5.366156675267107, 'solver': 'liblinear'}
    XGB: {'n_estimators': 473, 'max_depth': 4, 'learning_rate': 0.005726464156884109, 'subsample': 0.8820362099340904, 'colsample_bytree': 0.6038723206241715, 'gamma': 1.6810668392690635, 'reg_alpha': 0.7331456904581268, 'reg_lambda': 0.5085616047004449}
    LGBM: {'num_leaves': 28, 'learning_rate': 0.022649794660349016, 'n_estimators': 277, 'feature_fraction': 0.6000156786962209, 'lambda_l1': 0.5036226962512214, 'lambda_l2': 0.15331867481902328}
    TabNet: {'n_d': 58, 'n_a': 64, 'n_steps': 10, 'gamma': 1.0260856760812516, 'lambda_sparse': 0.0030223562078505292, 'momentum': 0.5236943812424828}
    

    Regresión Logística:

    In [49]:
    # Regresión logística:
    
    params_logreg.update({"class_weight": "balanced", "max_iter": 500})
    model_logreg = LogisticRegression(**params_logreg)
    model_logreg.fit(X_train_processed, y_train)
    modelos["LogisticRegression"] = model_logreg
    

    XGBoost:

    In [50]:
    # XGBoost
    
    params_xgb.update({"scale_pos_weight": scale_pos_weight})
    results_xgb = {}
    model_xgb = XGBClassifier(**params_xgb, use_label_encoder=False, eval_metric="auc")
    model_xgb.fit(
        X_train_processed, y_train,
        eval_set=[(X_train_processed, y_train), (X_val_processed, y_val)],
        verbose=False
    )
    
    results_xgb = model_xgb.evals_result()
    modelos["XGBoost"] = model_xgb
    curvas["XGBoost"] = results_xgb
    
    c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
    
    [15:35:59] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740: 
    Parameters: { "use_label_encoder" } are not used.
    
    
    

    LigthGBM:

    In [51]:
    # LightGBM
    
    params_lgbm.update({"is_unbalance": True})
    results_lgbm = {} 
    model_lgbm = LGBMClassifier(**params_lgbm, objective="binary")
    model_lgbm.fit(
        X_train_processed, y_train,
        eval_set=[(X_train_processed, y_train), (X_val_processed, y_val)],
        eval_metric="auc"
    )
    
    results_lgbm = model_lgbm.evals_result_
    modelos["LightGBM"] = model_lgbm
    curvas["LightGBM"] = results_lgbm
    
    [LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209
    [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214
    [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328
    [LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209
    [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214
    [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328
    [LightGBM] [Info] Number of positive: 13884, number of negative: 367051
    [LightGBM] [Info] Auto-choosing row-wise multi-threading, the overhead of testing was 0.016348 seconds.
    You can set `force_row_wise=true` to remove the overhead.
    And if memory is not enough, you can set `force_col_wise=true`.
    [LightGBM] [Info] Total Bins 970
    [LightGBM] [Info] Number of data points in the train set: 380935, number of used features: 46
    [LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209
    [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214
    [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328
    [LightGBM] [Info] [binary:BoostFromScore]: pavg=0.036447 -> initscore=-3.274764
    [LightGBM] [Info] Start training from score -3.274764
    

    Tabnet:

    In [52]:
    # TabNet
    model_tabnet = TabNetClassifier(**params_tabnet, verbose=0)
    model_tabnet.fit(
        X_train_processed, y_train.values,
        eval_set=[(X_train_processed, y_train.values), (X_val_processed, y_val.values)],
        eval_metric=['auc'],
        patience=10,
        max_epochs=10,
        batch_size=128,
        virtual_batch_size=32,
        drop_last=False
    )
    
    modelos["TabNet"] = model_tabnet
    curvas["TabNet"] = {
        "train_auc": model_tabnet.history['val_0_auc'],
        "valid_auc": model_tabnet.history['val_1_auc']}
    
    Stop training because you reached max_epochs = 10 with best_epoch = 3 and best_val_1_auc = 0.57215
    
    c:\Program Files\Python311\Lib\site-packages\pytorch_tabnet\callbacks.py:172: UserWarning:
    
    Best weights from best epoch are automatically used!
    
    
    In [53]:
    # Inspecciono artefacto Curvas:
    
    for modelo, contenido in curvas.items():
        print(f"\n Modelo: {modelo}")
        if isinstance(contenido, dict):
            for clave, valor in contenido.items():
                if isinstance(valor, dict):
                    print(f"  Subclave: {clave} --> Keys internas: {list(valor.keys())}")
                elif isinstance(valor, list):
                    print(f"  {clave}: Lista de longitud {len(valor)} (valores tipo: {type(valor[0])})")
                else:
                    print(f" {clave}: {type(valor)}")
        else:
            print(f"  Contenido directo: {type(contenido)}")
    
     Modelo: XGBoost
      Subclave: validation_0 --> Keys internas: ['auc']
      Subclave: validation_1 --> Keys internas: ['auc']
    
     Modelo: LightGBM
      Subclave: training --> Keys internas: ['auc', 'binary_logloss']
      Subclave: valid_1 --> Keys internas: ['auc', 'binary_logloss']
    
     Modelo: TabNet
      train_auc: Lista de longitud 10 (valores tipo: <class 'numpy.float64'>)
      valid_auc: Lista de longitud 10 (valores tipo: <class 'numpy.float64'>)
    
    In [54]:
    # Inspecciono artefacto Modelos:
    
    for modelo, contenido in modelos.items():
        print(f"\nModelo: {modelo}")
        if isinstance(contenido, dict):
            for clave, valor in contenido.items():
                if isinstance(valor, dict):
                    print(f"  Subclave: {clave} --> Keys internas: {list(valor.keys())}")
                elif isinstance(valor, list):
                    print(f"  {clave}: Lista de longitud {len(valor)} (valores tipo: {type(valor[0])})")
                else:
                    print(f" {clave}: {type(valor)}")
        else:
            print(f"  Contenido directo: {type(contenido)}")
    
    Modelo: LogisticRegression
      Contenido directo: <class 'sklearn.linear_model._logistic.LogisticRegression'>
    
    Modelo: XGBoost
      Contenido directo: <class 'xgboost.sklearn.XGBClassifier'>
    
    Modelo: LightGBM
      Contenido directo: <class 'lightgbm.sklearn.LGBMClassifier'>
    
    Modelo: TabNet
      Contenido directo: <class 'pytorch_tabnet.tab_model.TabNetClassifier'>
    
    In [55]:
    # Visualización de las curvas de validación de los modelos basados en ensemble (más de 200 iteraciones de entrenamiento):
    
    plt.figure(figsize=(12, 7))
    plt.title("Curvas AUC de Validación por Modelo", fontsize=16)
    plt.xlabel("Iteración / Época", fontsize=12)
    plt.ylabel("AUC en Validación", fontsize=12)
    plt.grid(True, linestyle='--', alpha=0.5)
    
    colores = {
        "XGBoost": "#1f77b4",
        "LightGBM": "#17becf",
        "TabNet": "#08306b"
    }
    
    for modelo in curvas:
        if modelo == "XGBoost":
            aucs = curvas[modelo]["validation_1"]["auc"]
        elif modelo == "LightGBM":
            aucs = curvas[modelo]["valid_1"]["auc"]
        else:
            continue
    
        x = list(range(len(aucs)))
        color = colores.get(modelo, "blue")
        plt.plot(x, aucs, label=f"{modelo}", linewidth=2.5, color=color)
        max_idx = int(np.argmax(aucs))
        max_val = aucs[max_idx]
        plt.scatter(max_idx, max_val, color=color, edgecolor='white', zorder=5, s=80)
    
    plt.legend(title="Modelos", fontsize=11, title_fontsize=12, loc="lower right")
    plt.tight_layout()
    plt.show()
    
    No description has been provided for this image

    Evaluación sobre el conjunto de test:

    In [56]:
    # Evaluación de las métricas sobre el conjunto de test:
    
    metricas = {}
    
    for nombre, modelo in modelos.items():
        try:
            if nombre == "TabNet":
                y_prob = modelo.predict_proba(X_test_processed)[:, 1]
                y_pred = modelo.predict(X_test_processed).ravel()
            else:
                y_prob = modelo.predict_proba(X_test_processed)[:, 1]
                y_pred = modelo.predict(X_test_processed)
    
            metricas[nombre] = {
                "ROC-AUC": roc_auc_score(y_test, y_prob),
                "PR-AUC": average_precision_score(y_test, y_prob),
                "Recall": recall_score(y_test, y_pred),
                "F1-score": f1_score(y_test, y_pred),
                "Precision": precision_score(y_test, y_pred),
                "Accuracy": accuracy_score(y_test, y_pred)
            }
            
        except Exception as e:
            print(f"Error al evaluar {nombre}: {e}")
    
    df_metricas = pd.DataFrame(metricas).T.sort_values(by="ROC-AUC", ascending=False).round(4)
    print("\n Métricas en el conjunto de test:")
    df_metricas
    
    [LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209
    [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214
    [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328
    [LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209
    [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214
    [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328
    
     Métricas en el conjunto de test:
    
    Out[56]:
    ROC-AUC PR-AUC Recall F1-score Precision Accuracy
    XGBoost 0.6117 0.0568 0.5545 0.0914 0.0498 0.5979
    LogisticRegression 0.6114 0.0555 0.5469 0.0920 0.0502 0.6065
    LightGBM 0.6109 0.0566 0.5582 0.0928 0.0506 0.6023
    TabNet 0.5841 0.0487 0.0002 0.0005 0.1429 0.9635
    In [57]:
    # GUARDADO DE MODELOS
    
    for nombre, modelo in modelos.items():
        with open(f"{version_dir}/modelo_trained_{nombre}_{version}.pkl", "wb") as f:
            pickle.dump(modelo, f)
    
    print(f"Modelos guardados correctamente en '{version_dir}' con sufijo de versión.")
    
    Modelos guardados correctamente en 'results/tfm_rsin_v00' con sufijo de versión.
    

    Consideraciones de la evaluación - Mejor modelo:

    Nuestro target indica la ocurrencia de un siniestro, cuya correcta detección es crítica para una gestión eficiente del riesgo asegurador en la compañía. Su criticidad es elevada y su eventualidad, remota (evento altamente desbalanceado, ~4%), lo cual ha sido contrastado durante todo el análisis empírico.

    Modelos basados en ensemble de árboles (XGBoost / LightGBM)¶

    Ambos modelos presentan un rendimiento competitivo en términos de capacidad discriminante, alcanzando valores de ROC-AUC superiores a 0.61. Este resultado sugiere una adecuada separación entre las clases a lo largo del conjunto completo de umbrales posibles, lo cual es relevante en contextos donde el umbral óptimo no está previamente definido. Adicionalmente, se observa un valor de PR-AUC por encima de 0.056, que debe contextualizarse cuidadosamente: dado que la tasa base de siniestros en el conjunto de datos es ≈ 4%, el rendimiento de un clasificador aleatorio oscilaría en torno a ese valor. Por tanto, superar significativamente esta cota implica que el modelo es capaz de identificar con cierta eficacia la clase positiva, incluso en presencia de un desequilibrio extremo. Podemos fijarnos adicionalmente en las matrices que derivan de la matriz de confusión, en un umbral fijado, si bien esto se desarrollará a posteriori para el mdjor modelo... El comportamiento de las métricas recall** y **F1-score resulta especialmente destacable. El recall moderado asegura una baja tasa de falsos negativos, lo cual es fundamental en problemas de riesgo donde omitir un evento crítico (como un siniestro) puede tener consecuencias operativas y económicas graves. Asimismo, el F1-score consistente denota una estabilidad en la predicción de la clase positiva, sin incurrir en una pérdida desproporcionada de precisión. En conjunto, estos resultados refuerzan la idoneidad de los modelos basados en árboles de decisión para esta problemática, al ofrecer una sensibilidad adecuada sin comprometer la estabilidad general del modelo.

    Asimismo, cabe destacar que su estructura aditiva de árboles de decisión, es capaz de modelar interacciones no lineales, inherentes a entornos regulatorios y datos aseguradores.

    Regresión Logística¶

    A pesar de ser el benchmark tradicional en problemas de riesgo por su trazabilidad y facilidad de interpretación, su marco lineal puede limitar la detección de patrones no triviales, especialmente en presencia de múltiples variables categóricas complejas. Su performance es más que adecuado, y muy cercano a las consideraciones que se han desprendido de los ensembles, detacando como sigue siendo una solución factible y eficaz ante planteamientos de medición del riesgo.

    TabNet¶

    A pesar de su capacidad teórica en representación jerárquica, en este problema TabNet ofrece un accuracy artificialmente alto (96.35%) con un recall prácticamente nulo, revelando que simplemente predice la clase mayoritaria. Si bien en sus métricas agregadas de diferenciación, se observa una menor capacidad de distinción de clases ROC-AUC y PR-AUC, elementos principales los los que se descartaría a nivel objetivo el modelo aunque, podemos ir más allá en este caso...

    Ni su estructura atencional ni sus capacidades adaptativas parecen aportar mejoras en este problema, incluso tras calibración de batch_size, patience o virtual_batch_size. Su PR-AUC < 0.05 y su muy elevado costo computacional del proceso de entrenamiento refuerzan las voluntades de desconsideración de este modelo de estado del arte.


    Decisión final: árboles de decisión ensamblados (boosting)¶

    Dado el análisis anterior, se concluye que el modelo óptimo es XGBoost. A diferencia de TabNet o la regresión logística, además este modelo garantiza:

    • Estabilidad entre ejecuciones con hiperparámetros estables.
    • Robustez ante variables categóricas codificadas, sin pérdidas de rendimiento.
    • Capacidad de trazabilidad gracias a SHAP y Partial Dependence Plots.

    Mejor modelo (criterio, max auc):

    In [58]:
    mejor_modelo_nombre = df_metricas.index[0]
    mejor_modelo = modelos[mejor_modelo_nombre]
    print(f"\n Mejor modelo en términos relativos al resto: {mejor_modelo_nombre}")
    
     Mejor modelo en términos relativos al resto: XGBoost
    

    Diagnosis de la matriz de confusión del mejor modelo seleccionado:

    Visualización de una matriz de Confusión Teórica sobre un caso de uso binario:¶
    Real \ Predicho 0 (Predicho) 1 (Predicho)
    0 (Real) TN FP
    1 (Real) FN TP
    Insights Relevantes para nuestro problema¶

    El análisis de la matriz de confusión para distintos cutoffpuede revelar patrones clave que orienten la toma de decisiones en la configuración final del modelo de predicción de siniestralidad. Como se ha citado con anterioridad, esto nos puede acercar más/menos a nuestro negocio, pero mantendremos el poder discriminante global del modelo. Por esa razón, en este paartado visualizaremos comos e comporta el clasificador del XGBoost en función de los distintos cutt-off de 0 a 1.

    Es muy importante este proceso, ya que el buen entendimiento de la matriz de confusión pasa por entender la lógica de un clasificador. [AQUI degradación de la matriz]

    1. En base a eso, dotamos de un objetivo principal: maximizar los TP (True Positives), es decir, detectar correctamente los siniestros reales. En términos métricos, esto se traduce en maximizar el recall y minimizar los FN (False Negatives), ya que fallar en esta clase implica no activar mecanismos de cobertura o prevención donde realmente corresponde.
    2. La accuracy sigue siendo una métrica relevante desde el punto de vista del rendimiento global. Por ello, se busca un equilibrio en el que:
      • La proporción de TN (True Negatives) se mantenga alta, para no degradar la fiabilidad general del sistema.
      • El volumen de FP (False Positives) no crezca de forma descontrolada, lo cual perjudicaría la precision y podría acarrear consecuencias económicas por falsas alarmas.

    Este equilibrio entre sensitividad y especificidad es esencial en entornos donde la clase positiva es escasa pero crítica, como es el caso del riesgo de siniestralidad.

    In [59]:
    # Predicciones de probabilidad:
    y_prob_mejor = mejor_modelo.predict_proba(X_test_processed)[:, 1]
    
    # Cutoffs a evaluar:
    cutoffs = np.round(np.arange(0.0, 1.0, 0.1), 2)
    
    # viz:
    fig, axs = plt.subplots(2, 5, figsize=(22, 10))
    axs = axs.flatten()
    
    for i, cutoff in enumerate(cutoffs):
        y_pred_cutoff = (y_prob_mejor >= cutoff).astype(int)
        cm = confusion_matrix(y_test, y_pred_cutoff)
    
        disp = ConfusionMatrixDisplay(confusion_matrix=cm)
        disp.plot(ax=axs[i], cmap='Blues', colorbar=False)
        axs[i].set_title(f"Cutoff = {cutoff}", fontsize=12)
        axs[i].grid(False)
    
    plt.suptitle(f"Matrices de Confusión del Modelo Óptimo: {mejor_modelo_nombre}", fontsize=18, y=1.02)
    plt.tight_layout()
    plt.show()
    
    No description has been provided for this image

    Relevancia en la visualización de la matriz de conf. en distintos cut-off's:

    La visualización de las distintas matrices de confusión "raw", en función del valor del cut-off, nos muestran como un valor de dicho umbral >= 0.4 e < 0.5 sería el adecuad pues mantiene: TN vs FP elevados y TP vs FN elevados, respectivamente.

    La designación del mismo pasa por criterios puros de negocio. E.g el considerar un umbral igual a 0.4 implica una correcta detección de los siniestros (recall elevado), pero una penalización sobre casos sin siniestro asumiendo que estos tienen... (casos FP del cuadrante superior derecho). Asimismo, el considerar un umbral de 0.5, implica en penalizar a muchos menos usaurios que no disponen de siniestro (menor valor de FP), que son degradados de manera natural desde bucket FP hacia bucket TN, sim embargo, implica una reducción considerable del recall, debido a la degradación de casos TP a FN en los cuadrantes inferiores.

    En este punto, ya intervienen las distintas políticas de negocio de la compañía, y/o las fases en las que se busque identificar dicha siniestralidad. Por ejemplo, sobre una primera preselección de casos potenciales a siniestro, un umbral de 0.45 sería adecuado.

    A continuación, todas estas consideraciones, se plasman en formato curva, con el plotting de los cuadrantes de la matriz en función del cutt-off, así como de las diferentes métricas que se derivan de estos.

    In [60]:
    cutoffs = np.round(np.arange(0.0, 1.0, 0.1), 2)
    tns, fps, fns, tps = [], [], [], []
    
    # Cálculo de la matriz de confusión para cada cutoff
    for cutoff in cutoffs:
        y_pred_cutoff = (y_prob_mejor >= cutoff).astype(int)
        tn, fp, fn, tp = confusion_matrix(y_test, y_pred_cutoff).ravel()
        tns.append(tn)
        fps.append(fp)
        fns.append(fn)
        tps.append(tp)
    fig, ax1 = plt.subplots(figsize=(12, 6))
    
    # Eje para TN y FP:
    ax1.plot(cutoffs, tns, marker='o', label="True Negatives (TN)")
    ax1.plot(cutoffs, fps, marker='o', label="False Positives (FP)")
    ax1.set_xlabel("Cutoff", fontsize=12)
    ax1.set_ylabel("TN / FP", fontsize=12)
    ax1.tick_params(axis='y')
    ax1.grid(True, linestyle='--', alpha=0.5)
    
    # Eje para FN y TP:
    ax2 = ax1.twinx()
    ax2.plot(cutoffs, fns, 'g--o', label="False Negatives (FN)")
    ax2.plot(cutoffs, tps, 'r--o', label="True Positives (TP)")
    ax2.set_ylabel("FN / TP", fontsize=12)
    ax2.tick_params(axis='y')
    
    # Unión de ambos ejes
    lines_1, labels_1 = ax1.get_legend_handles_labels()
    lines_2, labels_2 = ax2.get_legend_handles_labels()
    ax1.legend(lines_1 + lines_2, labels_1 + labels_2, loc='center right')
    
    plt.title(f"Evolución de la Matriz de Confusión - {mejor_modelo_nombre}", fontsize=16)
    plt.xticks(cutoffs)
    plt.tight_layout()
    
    plt.show()
    
    No description has been provided for this image
    In [61]:
    # Cutoffs a evaluar
    cutoffs = np.round(np.arange(0.0, 1.0, 0.1), 2)
    precisions, recalls, f1s, accuracies = [], [], [], []
    for cutoff in cutoffs:
        y_pred_cutoff = (y_prob_mejor >= cutoff).astype(int)
        precisions.append(precision_score(y_test, y_pred_cutoff, zero_division=0))
        recalls.append(recall_score(y_test, y_pred_cutoff, zero_division=0))
        f1s.append(f1_score(y_test, y_pred_cutoff, zero_division=0))
        accuracies.append(accuracy_score(y_test, y_pred_cutoff))
    
    plt.figure(figsize=(12, 6))
    plt.plot(cutoffs, precisions, marker='o', label="Precision")
    plt.plot(cutoffs, recalls, marker='o', label="Recall")
    plt.plot(cutoffs, f1s, marker='o', label="F1-score")
    plt.plot(cutoffs, accuracies, marker='o', label="Accuracy")
    plt.title(f"Evolución de Métricas - {mejor_modelo_nombre}", fontsize=16)
    plt.xlabel("Cutoff", fontsize=12)
    plt.ylabel("Valor de la Métrica", fontsize=12)
    plt.xticks(cutoffs)
    plt.grid(True, linestyle='--', alpha=0.5)
    plt.ylim(0, 1.05)
    plt.legend()
    plt.tight_layout()
    
    plt.show()
    
    No description has been provided for this image

    Consideraciones finales:

    Limitaciones de Modelado Supervisado en Entornos de Alto Desbalance

    Los resultados obtenidos tras la implementación y evaluación exhaustiva de modelos supervisados —Logistic Regression, XGBoost, LightGBM y TabNet— revelan una capacidad limitada para discriminar adecuadamente a los clientes siniestrados en contextos fuertemente desbalanceados.

    A pesar de haber incorporado técnicas de compensación estándar como la ponderación de clases (class_weight="balanced"), el ajuste explícito de scale_pos_weight en modelos de boosting, y el uso de funciones de pérdida adaptadas, el rendimiento sobre la clase minoritaria ha permanecido moderado.

    Esto es un fenómeno estructural: la insuficiente separabilidad entre clases en el espacio de representación inducido por las variables disponibles, posiblemente debido a la naturaleza ofuscada, limitada o de las variables predictoras.

    En otras palabras, no es el modelo el que falla, sino el conjunto de información que este puede explotar.

    El análisis realizado confirma que el empleo de algoritmos sofisticados no garantiza, por sí solo, un rendimiento adecuado cuando el problema de base posee una señal débil o está contaminado por ruido irreducible.

    Además, en dominios regulados como el financiero, donde la interpretabilidad y la estabilidad son tan importantes como la precisión, estas limitaciones no son únicamente estadísticas, sino también operativas.

    Por tanto, se concluye que la mejora del rendimiento predictivo en contextos de riesgo de siniestralidad no depende exclusivamente de la elección del modelo, sino de la ampliación y enriquecimiento del espacio de variables predictoras.

    Por ello, se recomienda, para futuros casos de uso:

    • El desarrollo de variables derivadas mediante análisis de series temporales de comportamiento financiero.
    • La incorporación de información contextual, macroeconómica o derivada de redes relacionales entre clientes.
    • El empleo de técnicas de representación densa (e.g., embeddings) o aprendizaje contrastivo para capturar estructuras latentes.
    • La exploración de modelos causales que permitan desambiguar correlaciones espurias de relaciones estructurales.

    El presente trabajo muestra que el rigor metodológico en la validación y ajuste de modelos no siempre conlleva una mejora sustancial cuando las limitaciones son intrínsecas al sistema de información, y sienta las bases para una línea de investigación centrada en la mejora del valor informativo más allá de la arquitectura del modelo.


    A pesar de obtener múltiples modelos, el recall y la precisión siguen siendo bajos, lo que refuerza la hipótesis de que la limitación es estructural y no técnica.

    Del siniestro al "riesgo de siniestro": Calibración del espacio de probabilidad

    En la predicción del riesgo de siniestralidad, la calibración del modelo resulta fundamental cuando se requiere que las probabilidades predichas sean interpretadas directamente como frecuencias observables. A continuación se expone una motivación basada en escenarios reales del dominio asegurador.


    Ejemplo aplicado: riesgo asegurador

    Supóngase que un modelo predice una probabilidad de siniestro del 0.60 para un individuo. No obstante, al analizar empíricamente dicho segmento, se constata que la proporción real de siniestros es del 10%. En este caso, el modelo está sobrestimando el riesgo, lo cual puede inducir decisiones erróneas desde una perspectiva operativa o de negocio.

    Caso 1: Pricing de seguros

    Cuando se utiliza directamente la probabilidad predicha como entrada del sistema de tarificación:

    • Se podría asignar una prima excesiva a clientes con riesgo moderado, comprometiendo la competitividad.
    • Se puede infravalorar el coste real en clientes verdaderamente riesgosos, afectando la rentabilidad técnica.
    Por tanto, se debe asegurar que las probabilidades estén correctamente calibradas.

    Caso 2: Selección de cartera o underwriting

    En procesos donde se establece un umbral de aceptación (cutoff), una mala calibración puede provocar:

    • Rechazo de buenos clientes debido a sobrestimación del riesgo.
    • Aceptación de clientes de alto riesgo si se subestima la probabilidad.
    Se debe entonces decidir sobre umbrales en base a scores calibrados.

    Cuando se selecciona el top 5% de mayor riesgo para una auditoría o intervención, se debe verificar que:

    • El orden del score sea adecuado (discriminación: ROC-AUC).
    • La proporción predicha coincida con la observada, especialmente si se estiman pérdidas agregadas.

    Conclusión técnica

    La calibración no mejora la discriminación del modelo, pero alinea las probabilidades con la frecuencia empírica. Esto es crucial cuando:

    • Se requiere consistencia y trazabilidad del sistema.
    • Se estiman pérdidas esperadas como:

      E[Pérdida] = P(Siniestro) × Severidad

    Se debe implementar calibración con métodos como regresión isotónica o Platt scaling en los casos en los que las probabilidades del modelo sean utilizadas como decisiones probabilísticas reales.


    La calibración es una herramienta esencial cuando la probabilidad predicha tiene una interpretación directa en decisiones operativas, y su correcta implementación eleva la fiabilidad del sistema de toma de decisiones.

    Isotonic regresion

    La regresión isotónica es una técnica no paramétrica utilizada para modelar una función monótona creciente a partir de datos empíricos. Su aplicación más relevante en problemas de clasificación binaria es la calibración de probabilidades, ajustando la salida de un modelo predictivo (score) para que refleje correctamente la probabilidad empírica de ocurrencia del evento positivo.

    Sea un conjunto de pares ordenados ((s_i, y_i)), donde (s_i) es el score generado por un modelo (por ejemplo, la salida de XGBoost antes de aplicar el umbral) y (y_i entre {0,1}) la clase real observada. La regresión isotónica busca una función monótona creciente (f) que minimice el error cuadrático medio entre la predicción calibrada y la clase real:

    $$ \min_{f \in \mathcal{M}} \sum_{i=1}^n (f(s_i) - y_i)^2 \\ \text{donde } \mathcal{M} = \{ f \text{ monótona creciente} \} $$

    Este ajuste se realiza típicamente mediante el algoritmo de Pool Adjacent Violators (PAV), que encuentra la mejor aproximación por tramos constantes no decrecientes. Esta técnica es no paramétrica, lo que la hace especialmente flexible frente a distribuciones sesgadas o altamente desbalanceadas, como ocurre en riesgo de siniestralidad (nuestro caso de uso). Además, preserva la ordenación relativa, manteniendo su utilidad como ranking.

    • No impone forma funcional sobre la relación entre el score y la probabilidad real: ideal en problemas donde esta relación puede ser piecewise o no sigmoidea.
    • Permite una interpretación probabilística más fiel, especialmente en regiones donde el modelo original tiende a sobreestimar o infraestimar riesgos.
    • Su naturaleza no paramétrica implica mayor flexibilidad, aunque también mayor riesgo de sobreajuste si el conjunto de calibración es pequeño o poco representativo.

    En el ámbito asegurador, es común encontrar modelos que generan scores adecuados desde un punto de vista de ordenación (ROC-AUC), pero que fallan en traducir estos scores a probabilidades calibradas. En estos casos, isotonic regression actúa como una capa post-hoc que transforma los scores en una escala probabilística más realista, sin modificar el modelo original.

    Este método constituye una herramienta potente cuando se requiere interpretar la salida del modelo como una probabilidad fiable. Su uso es especialmente relevante en entornos regulados o de alta criticidad (como seguros o crédito), donde las decisiones no solo deben ser correctas, sino cuantitativamente justificables.

    Aplicación sobre XGboost

    El modelo XGBoost, al igual que otros algoritmos basados en gradient boosting, tiende a producir valores extremos (muy cercanos a 0 o 1) en sus predicciones probabilísticas, lo que implica una sobreconfianza en sus decisiones. A pesar de obtener buenos valores de ROC-AUC, su output no refleja necesariamente probabilidades empíricas bien calibradas.

    Por tanto, es razonable aplicar un método de calibración post-hoc que mapea los scores del modelo a probabilidades reales observadas. Una solución robusta y ampliamente aceptada es el uso del wrapper de `scikit-learn` CalibratedClassifierCV, que permite calibrar cualquier estimador compatible con el método predict_proba, como es el caso de `XGBClassifier`.

    Este wrapper permite calibrar el modelo en una estrategia de validación cruzada o en un conjunto de validación separado, ajustando una función de calibración como en este caso isotonic. La calibración se realiza sin reentrenar completamente el modelo base, simplemente ajustando una capa superior de transformación probabilística.

    Así, se conserva el poder predictivo del modelo original, pero con una salida más alineada con la frecuencia real de ocurrencia del evento positivo, lo cual es crítico para tareas de toma de decisión reguladas o sensibles. Asimismo:

    • Permite mantener la estructura y parámetros del modelo XGBoost entrenado.
    • No requiere modificaciones al preprocesamiento ni al pipeline ya entrenado.
    • Mejora la interpretabilidad y confiabilidad de la probabilidad como score final.

    Es importante destacar que CalibratedClassifierCV requiere que el modelo no esté previamente calibrado internamente (como podría ocurrir si se aplicara un entrenamiento con log_loss y regularización extrema). Por ello, lo empleamos tras entrenar el modelo con una métrica de ordenación como auc, como se ha hecho en el presente caso de uso.

    In [62]:
    # Calibrador con regresión isotónica y clase CalibratedClassifierCV:
    calibrador = CalibratedClassifierCV(
        estimator=mejor_modelo,
        method='isotonic',
        cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    )
    
    # Entrenamiento sobre conjunto de entrenamiento:
    calibrador.fit(X_train_processed, y_train)
    
    # Probabilidades sobre test:
    y_prob_original = mejor_modelo.predict_proba(X_test_processed)[:, 1]
    y_prob_calibrado = calibrador.predict_proba(X_test_processed)[:, 1]
    
    c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
    
    [17:22:13] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740: 
    Parameters: { "use_label_encoder" } are not used.
    
    
    c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
    
    [17:22:20] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740: 
    Parameters: { "use_label_encoder" } are not used.
    
    
    c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
    
    [17:22:28] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740: 
    Parameters: { "use_label_encoder" } are not used.
    
    
    c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
    
    [17:22:35] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740: 
    Parameters: { "use_label_encoder" } are not used.
    
    
    c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
    
    [17:22:42] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740: 
    Parameters: { "use_label_encoder" } are not used.
    
    
    
    In [63]:
    # Calibración sobre 10 bins:
    prob_true_orig, prob_pred_orig = calibration_curve(y_test, y_prob_original, n_bins=10, strategy='quantile')
    prob_true_cal, prob_pred_cal = calibration_curve(y_test, y_prob_calibrado, n_bins=10, strategy='quantile')
    
    fig = plt.figure(figsize=(16, 10))
    gs = fig.add_gridspec(2, 1, height_ratios=[2.5, 1], hspace=0.3)
    
    # Curva:
    ax0 = fig.add_subplot(gs[0])
    ax0.plot([0, 1], [0, 1], 'k--', label='Calibración perfecta')
    ax0.plot(prob_pred_orig, prob_true_orig, 'o--', label='Original', color='steelblue')
    ax0.plot(prob_pred_cal, prob_true_cal, 'o-', label='Calibrado (Isotonic)', color='darkorange')
    ax0.set_title(f"Curva de Calibración - {mejor_modelo_nombre}", fontsize=14)
    ax0.set_xlabel("Probabilidad predicha")
    ax0.set_ylabel("Proporción real de positivos")
    ax0.set_xlim([0, 1])
    ax0.set_ylim([0, 1])
    ax0.grid(True, linestyle='--', alpha=0.6)
    ax0.legend(loc="upper left")
    
    ax1 = fig.add_subplot(gs[1])
    sns.histplot(y_prob_original, bins=20, label='Original', color='steelblue', alpha=0.5)
    sns.histplot(y_prob_calibrado, bins=20, label='Calibrado', color='darkorange', alpha=0.5)
    ax1.set_xlabel("Probabilidad predicha")
    ax1.set_ylabel("Frecuencia")
    ax1.set_xlim([0, 1])
    ax1.legend()
    ax1.grid(True, linestyle='--', alpha=0.4)
    
    plt.tight_layout()
    
    plt.show()
    
    C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\2234182741.py:30: UserWarning:
    
    This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
    
    
    No description has been provided for this image
    In [64]:
    # Brier Score como métrica para conocer que se ha calibrado:
    brier_original = brier_score_loss(y_test, y_prob_original)
    brier_calibrated = brier_score_loss(y_test, y_prob_calibrado)
    
    print(f"Brier Score (original): {brier_original:.4f}")
    print(f"Brier Score (calibrado): {brier_calibrated:.4f}")
    
    Brier Score (original): 0.2389
    Brier Score (calibrado): 0.0349
    

    Resultados de la calibración:

    • Antes de calibrar, el modelo sobreestimaba la probabilidad de siniestro. La curva de calibración azul se encontraba por debajo de la diagonal, reflejando dicha sobreconfianza.
    • Después de calibrar con regresión isotónica, las probabilidades se distribuyen en [0, 0.1] y la curva naranja se aproxima mucho más a la diagonal ideal. Esto mejora la correspondencia entre probabilidad predicha y frecuencia observada.

    El Brier Score mide la media del error cuadrático entre las probabilidades predichas y las etiquetas reales (0 o 1). Cuanto más bajo, mejor calibrado está el modelo.

    Ejemplo visual del efecto de la calibración sobre un cliente (ID) con siniesto y sin siniestro:

    In [65]:
    y_test_array = np.array(y_test)
    idx_y0 = np.where(y_test_array == 0)[0][0]  # Primer ejemplo clase 0
    idx_y1 = np.where(y_test_array == 1)[0][0]  # Primer ejemplo clase 1
    
    # Probabilidades predichas para ambos casos:
    prob_orig_y0 = mejor_modelo.predict_proba(X_test_processed[idx_y0:idx_y0+1])[0, 1]
    prob_calib_y0 = calibrador.predict_proba(X_test_processed[idx_y0:idx_y0+1])[0, 1]
    
    prob_orig_y1 = mejor_modelo.predict_proba(X_test_processed[idx_y1:idx_y1+1])[0, 1]
    prob_calib_y1 = calibrador.predict_proba(X_test_processed[idx_y1:idx_y1+1])[0, 1]
    
    print(f"-Cliente sin siniestro (y=0) — riesgo original: {prob_orig_y0:.4f}, calibrado: {prob_calib_y0:.4f}")
    print(f"-Cliente con siniestro (y=1) — riesgo original: {prob_orig_y1:.4f}, calibrado: {prob_calib_y1:.4f}")
    
    -Cliente sin siniestro (y=0) — riesgo original: 0.4792, calibrado: 0.0345
    -Cliente con siniestro (y=1) — riesgo original: 0.3544, calibrado: 0.0162
    

    Este resultado es coherente con el comportamiento esperado de la calibración en escenarios como el nuestro (en este caso, ≈4% de siniestros), por:

    • El modelo no calibrado tiende a sobreestimar el riesgo global, asignando probabilidades infladas incluso a clientes que no presentan siniestros.
    • La regresión isotónica ajusta la escala probabilística para que los valores predichos reflejen las frecuencias reales observadas durante la validación cruzada.
    • Al haber pocos casos positivos, la calibración corrige la distribución bajando agresivamente las probabilidades para evitar falsas alarmas, especialmente en probabilidades mal separadas.

    Este ajuste no altera el ranking relativo de las predicciones, pero sí mejora la confianza probabilística en términos absolutos, aspecto fundamental para tareas como scoring, pricing o toma de decisiones reguladas.

    En este contexto, la calibración no mejora la discriminación entre clases, pero aporta interpretabilidad probabilística al modelo, asegurando que "riesgo 30%" significa verdaderamente "30% de clientes similares han tenido siniestros".

    Por tanto, este análisis empírico reafirma la necesidad de calibrar modelos cuando se busca interpretar probabilidades predichas de forma confiable. La diferencia entre predicción ordinal (ranking) y probabilística (calibración) es crítica en sistemas reales.

    6. Explicabilidad

    Interpretabilidad y Técnicas de XAI en Contextos Regulatorios

    En contextos complejos o regulados como es el presente, la interpretabilidad del modelo es tan importante como su rendimiento. Por esa razón, es imprescindible incorporar técnicas de explicabilidad —dentro del ámbito denominado explainable AI (XAI)— que permitan comprender el porqué de las predicciones, evaluar posibles sesgos y generar confianza en modelos complejos (como los de tipo ensemble o deep learning).

    El estado del arte en explicabilidad de modelos destaca principalmente dos enfoques: SHAP (SHapley Additive exPlanations) y LIME (Local Interpretable Model-agnostic Explanations). Ambos comparten el objetivo de interpretar modelos que suelen visualizarse como una "caja negra", pero lo hacen desde perspectivas distintas.

    LIME: explicaciones locales por aproximación

    • Enfoque: Perturba la instancia a explicar generando muestras similares y ajusta un modelo interpretable (usualmente lineal) en su vecindario.
    • Modelo-agnóstico: Se puede aplicar a cualquier tipo de modelo.
    • Interpretación: Genera explicaciones locales, válidas solo cerca del punto evaluado.
    • Ventaja: Rápido y fácil de implementar.
    • Limitación: Las explicaciones pueden ser inestables, sensibles al muestreo aleatorio.

    Ejemplo conceptual:

    Dado un cliente rechazado por el modelo de crédito, LIME genera versiones ligeramente modificadas del cliente (cambiando algunos atributos), observa cómo cambia la predicción y ajusta un modelo lineal local para explicar ese resultado.

    SHAP: teoría de juegos aplicada a la explicabilidad

    • Fundamento matemático: Basado en valores de Shapley (teoría de juegos cooperativos), asigna a cada variable una contribución justa al resultado.
    • Additividad: Las contribuciones suman la diferencia entre la predicción individual y la media del modelo.
    • Local y global: Permite interpretaciones tanto por instancia como agregadas.
    • Consistencia: Si una variable gana relevancia, su valor SHAP no disminuye.
    • Limitación: Costo computacional elevado, especialmente para modelos no optimizados.

    Implementaciones eficientes en Python:

    • TreeExplainer: para XGBoost, LightGBM o CatBoost — muy eficiente.
    • KernelExplainer: para modelos arbitrarios — más general, pero más lento.

    Comparativa técnica: SHAP vs LIME

    CaracterísticaLIMESHAP
    Fundamento teóricoAproximación local con modelo interpretableTeoría de juegos (valores de Shapley)
    Tipo de explicabilidadLocalLocal y global
    Modelo-agnósticoSíSí (más eficiente en árboles)
    EstabilidadDependiente del muestreoAlta estabilidad
    Costo computacional Bajo Medio - Alto
    Coherencia teóricaNo garantiza consistenciaAsegura propiedades deseables
    VisualizacionesBásicasAvanzadas: beeswarm, waterfall, force plots

    Explicabilidad local vs global


    Explicabilidad Local

    • Objetivo: Entender por qué el modelo ha hecho una predicción concreta para una instancia específica.
    • Nivel de análisis: Individual.
    • Aplicación típica: ¿Por qué se ha denegado el crédito a este cliente? ¿Qué variables influyeron en su score?
    • Métodos típicos:
      • LIME
      • SHAP (instancia a instancia: explainer.shap_values(X[i]))
      • Explicaciones contrafactuales (Counterfactual explanations)

    Ejemplo:

    Un cliente obtiene un score de 0.87 en un modelo de riesgo como nuestro caso de uso. La explicación local descompone esta predicción como una suma de contribuciones de cada variable para ese cliente en concreto.

    Explicabilidad Global

    • Objetivo: Comprender el comportamiento general del modelo sobre todo el dataset.
    • Nivel de análisis: Agregado.
    • Aplicación típica: ¿Qué variables son más importantes en general? ¿Cómo afecta un feature en concreto a las predicciones?
    • Métodos típicos:
      • SHAP (global: summary_plot, beeswarm)
      • Importancia de variables tradicional (gain, split)
      • Partial Dependence Plots (PDP)
      • Permutation Importance

    Ejemplo:

    Sobre un conjunto de n clientes, se observa que las variables que más influyen globalmente en el resultado del modelo de riesgo son X, Y y Z.

    Consideración:

    • La explicabilidad local ayuda a interpretar casos individuales, cruciales sobre decisiones automatizadas que afectan a un cliente/id en particular, por ejemplo, en la aplicación individual del modelo a la hora de evaluar la entrada de un cliente potencial en la aseguradora.
    • La explicabilidad global proporciona una visión agregada del modelo, útil para entender su lógica interna general, detectar sesgos sistemáticos y validar consistencia.
    In [66]:
    # DataFrame con nombres de columnas (lo necesitamos para LIME y SHAP):
    
    # Obtenemos nombres de columnas del One-Hot Encoding aplicado:
    cat_columns_encoded = encoder.get_feature_names_out(categorical_vars)
    
    # Concatenamos todos los nombres (en orden: categóricas, binarias, cuantitativas)
    column_names = np.concatenate([cat_columns_encoded, binary_vars, continuous_vars])
    
    # Creamos el DataFrame con nombres reales
    X_test_df = pd.DataFrame(X_test_processed, columns=column_names)
    
    print("Columnas reales recuperadas:")
    print(X_test_df.columns[:10])
    print(X_test_df)
    
    Columnas reales recuperadas:
    Index(['ps_car_02_cat_-1.0', 'ps_car_02_cat_0.0', 'ps_car_02_cat_1.0',
           'ps_car_03_cat_-1.0', 'ps_car_03_cat_0.0', 'ps_car_03_cat_1.0',
           'ps_car_07_cat_-1.0', 'ps_car_07_cat_0.0', 'ps_car_07_cat_1.0',
           'ps_car_04_cat_0.0'],
          dtype='object')
            ps_car_02_cat_-1.0  ps_car_02_cat_0.0  ps_car_02_cat_1.0  \
    0                      0.0                0.0                1.0   
    1                      0.0                0.0                1.0   
    2                      0.0                1.0                0.0   
    3                      0.0                0.0                1.0   
    4                      0.0                1.0                0.0   
    ...                    ...                ...                ...   
    119038                 0.0                0.0                1.0   
    119039                 0.0                0.0                1.0   
    119040                 0.0                0.0                1.0   
    119041                 0.0                0.0                1.0   
    119042                 0.0                0.0                1.0   
    
            ps_car_03_cat_-1.0  ps_car_03_cat_0.0  ps_car_03_cat_1.0  \
    0                      1.0                0.0                0.0   
    1                      1.0                0.0                0.0   
    2                      1.0                0.0                0.0   
    3                      1.0                0.0                0.0   
    4                      0.0                0.0                1.0   
    ...                    ...                ...                ...   
    119038                 1.0                0.0                0.0   
    119039                 1.0                0.0                0.0   
    119040                 1.0                0.0                0.0   
    119041                 1.0                0.0                0.0   
    119042                 1.0                0.0                0.0   
    
            ps_car_07_cat_-1.0  ps_car_07_cat_0.0  ps_car_07_cat_1.0  \
    0                      0.0                0.0                1.0   
    1                      0.0                0.0                1.0   
    2                      0.0                0.0                1.0   
    3                      0.0                0.0                1.0   
    4                      0.0                0.0                1.0   
    ...                    ...                ...                ...   
    119038                 0.0                0.0                1.0   
    119039                 0.0                0.0                1.0   
    119040                 0.0                0.0                1.0   
    119041                 0.0                0.0                1.0   
    119042                 0.0                0.0                1.0   
    
            ps_car_04_cat_0.0  ...  ps_reg_02_1.8  ps_ind_06_bin  ps_ind_07_bin  \
    0                     1.0  ...            0.0            0.0            0.0   
    1                     1.0  ...            0.0            1.0            0.0   
    2                     0.0  ...            0.0            1.0            0.0   
    3                     1.0  ...            0.0            1.0            0.0   
    4                     1.0  ...            0.0            0.0            1.0   
    ...                   ...  ...            ...            ...            ...   
    119038                0.0  ...            0.0            0.0            0.0   
    119039                1.0  ...            0.0            0.0            0.0   
    119040                1.0  ...            0.0            0.0            1.0   
    119041                1.0  ...            0.0            1.0            0.0   
    119042                1.0  ...            0.0            0.0            0.0   
    
            ps_ind_17_bin  ps_ind_16_bin  ps_car_08_cat  ps_car_13  ps_car_12  \
    0                 0.0            1.0            1.0  -0.305634   0.404717   
    1                 0.0            0.0            0.0   0.925573   0.404717   
    2                 0.0            0.0            1.0   0.737699   1.331018   
    3                 0.0            0.0            1.0  -0.568187  -0.868016   
    4                 1.0            0.0            1.0   1.298715   0.880888   
    ...               ...            ...            ...        ...        ...   
    119038            0.0            1.0            1.0   0.980217   1.331018   
    119039            0.0            1.0            0.0   0.185329  -0.868016   
    119040            0.0            0.0            0.0   0.378881  -0.868016   
    119041            0.0            1.0            1.0   0.111294   0.404717   
    119042            0.0            1.0            1.0  -1.718987   1.625274   
    
            ps_reg_03  ps_car_14  
    0        0.658043   0.695908  
    1        0.436240   0.349483  
    2        1.272016   1.401177  
    3        1.284480   0.132113  
    4        0.010037   0.218773  
    ...           ...        ...  
    119038   1.588775   1.164890  
    119039  -1.040090  -0.167640  
    119040   1.087000  -0.248427  
    119041  -0.548643   0.593940  
    119042   1.480619   1.247362  
    
    [119043 rows x 47 columns]
    

    LIME

    In [67]:
    # Explicabilidad local con Lime sobre una observación:
    
    # Selección del primer ID (cliente) del dataset por trazabilidad (evitar la marcación con randint, si bien esta sería de la siguiente manera): 
    # i = np.random.randint(0, len(X_test_processed))
    
    i = 0
    
    explainer_lime = lime.lime_tabular.LimeTabularExplainer(
        training_data=X_test_processed,
        feature_names=X_test_df.columns.tolist(),
        class_names=['No', 'Sí'],
        mode='classification'
    )
    
    exp = explainer_lime.explain_instance(
        data_row=X_test_processed[i],
        predict_fn=mejor_modelo.predict_proba
    )
    
    exp.show_in_notebook(show_table=True)
    

    Análisis de una predicción individual con LIME

    LIME genera un modelo lineal interpretable localmente ajustado a una vecindad perturbada del punto a explicar, en este caso el primer registro del dataset.

    Probabilidades de predicción:

    • La probabilidad predicha es 0.48 para clase positiva (Sí) y 0.52 para clase negativa (No). El modelo, por tanto, se sitúa en una región que podríamos designar de elevada incertidumbre, muy próxima al umbral de decisión estándar (0.5), lo que refleja un caso de frontera estadística.
    • El modelo ha sido influenciado por múltiples variables con contribuciones marginales pequeñas, lo que sugiere que ninguna característica domina claramente la decisión local, sino que esta se sustenta en una acumulación de señales débiles. Los features más marcados son:

    🔵 ps_ind_17_bin = 0 → contribuye hacia la clase No:

    Esto significa que, cuando esta variable toma valor 0, la predicción final se empuja hacia la clase 0 (no siniestro). Desde una perspectiva estadística, en el entrenamiento el valor `0` de esta variable probablemente esté asociado con una menor probabilidad de siniestralidad.

    Por tanto, en este caso, su valor actúa como "protector" frente al riesgo.

    🟠 ps_reg_02_0.1 = 0 → contribuye hacia la clase Sí:

    La variable transformada `ps_reg_02_0.1 = 0` puede haber resultado de un one-hot encoding sobre una variable categórica o binned continua. Su valor `0` indica ausencia de esta categoría. Sin embargo, en este contexto específico, su ausencia **se asocia a un mayor riesgo**, lo que significa que otras categorías diferentes a la 0.1 presentan más siniestralidad.

    La observación en particular, presenta muchas variables con contribuciones pequeñas en direcciones opuestas. Esto implica que no hay un único factor dominante, sino que el modelo construye la probabilidad de forma acumulativa a partir de señales débiles. Esto es típico en modelos tabulares de riesgo donde la señal es difusa y "multicausal"

    • Los valores individuales de cada variable pueden favorecer o penalizar la predicción de siniestralidad, dependiendo de su asociación aprendida durante el entrenamiento
    • En LIME, un valor puede contribuir a la clase positiva o negativa dependiendo de su dirección estimada localmente. Es importante entender que esta contribución no es global, sino específica de la instancia analizada.
    • Este tipo de explicaciones son útiles para auditoría individual de decisiones, sobre todo en casos de frontera (como este: 52% vs 48%).

    El comportamiento del modelo en este punto refleja una región de baja separabilidad del espacio de representación. Esto puede deberse a una señal estructural débil o a una configuración de variables que no permite una separación lineal o no lineal clara.

    El uso de LIME ha permitido validar que, para esta instancia, la predicción es débilmente soportada por el modelo, lo que podría tener implicaciones en la confianza operativa de la decisión si esta instancia es crítica (ej. cliente de alto valor económico).

    SHAP

    Explicabilidad Local

    In [68]:
    # Muestreo para X test:
    X_sample = X_test_df.sample(100, random_state=42)  
    # SHAP es caro computacionalmente, por lo que visualizamos su carácter global sobre una muestra aleatoria de 100 obs.
    
    # Wrapper de predicción
    def predict_fn(X):
        return mejor_modelo.predict_proba(X)[:, 1]
    
    # Explainer y obtención de SHAP Values:
    explainer = shap.Explainer(predict_fn, X_sample)
    shap_values = explainer(X_sample)
    
    # Explicabilidad local sobre el primero:
    shap.plots.waterfall(shap_values[0])
    
    PermutationExplainer explainer: 101it [00:18,  3.74it/s]                         
    
    No description has been provided for this image

    Análisis local de explicabilidad – SHAP¶

    La predicción realizada por el modelo XGBoost para una observación concreta se explica mediante la descomposición aditiva proporcionada por SHAP (SHapley Additive exPlanations). En este caso, el valor predicho es:

    $$ f(x) = 0.497 $$

    partiendo de un valor esperado global (base rate del modelo):

    $$ \mathbb{E}[f(X)] = 0.485 $$

    Esto implica que la combinación específica de variables para este individuo ha generado un ligero incremento en el riesgo estimado de siniestralidad, con una contribución neta total de:

    $$ \sum_{i=1}^n \phi_i = +0.012 $$

    donde $$\phi_i$$ representa el valor SHAP de la variable $$i$$.

    Elementos clave del gráfico

    • Valor base: representa la expectativa del modelo antes de ver los datos (media de predicciones).
    • Barras rojas: variables que aumentan la predicción (acercan al resultado positivo).
    • Barras azules: variables que reducen la predicción (acercan al resultado negativo).

    Contribuciones individuales más relevantes¶

    • ps_car_13 = 1.154 → contribuye +0.04 al riesgo predicho. Es el efecto dominante. Este valor (por encima de la media poblacional) se interpreta como un marcador de riesgo elevado, posiblemente por su relación con características del vehículo o asegurado que aumentan la siniestralidad.
    • ps_ind_16_bin = 1 → contribuye −0.01, lo que sugiere que esta condición binaria se asocia con mayor estabilidad o menor riesgo.
    • ps_ind_06_bin = 0 → tiene un efecto positivo de +0.01, por lo que su ausencia indica cierta debilidad o mayor exposición al riesgo.
    • ps_reg_03 = −0.354 → con contribución −0.01, este valor reducido de una variable regional puede indicar un contexto geográfico de baja siniestralidad.
    • Otras variables aportan efectos marginales, configurando un patrón de agregación de microefectos que es típico en problemas de señal débil con múltiples features categóricas.

    Ventajas observadas

    • Transparencia total sobre el porqué de una predicción específica.
    • Permite contrastar y auditar decisiones de modelos complejos con respaldo matemático sólido (valores de Shapley).
    • Es particularmente útil en observaciones "fronterizas" o inesperadas, donde se requiere justificación precisa.

    Interpretación formal¶

    El modelo actúa como una función aditiva sobre la base media, mediante:

    $$ f(x) = \mathbb{E}[f(X)] + \sum_{i=1}^n \phi_i $$

    Esta propiedad asegura interpretabilidad y coherencia local. En este caso, la predicción final de 0.497 refleja una combinación equilibrada de señales débiles, sin dominancia extrema de ninguna variable salvo ps_car_13. La predicción se sitúa muy cerca del umbral de decisión 0.5, lo que confirma la incertidumbre del modelo sobre este individuo concreto.

    Explicabilidad Global

    In [69]:
    shap.plots.beeswarm(shap_values)
    
    No description has been provided for this image
    In [70]:
    shap.plots.bar(shap_values)
    
    No description has been provided for this image

    Explicabilidad global mediante SHAP¶

    Se ha llevado a cabo un análisis de explicabilidad global utilizando valores de SHAP (SHapley Additive exPlanations) sobre una muestra aleatoria de 100 observaciones del conjunto de test. Este enfoque permite evaluar cómo cada variable contribuye, en promedio, a las decisiones del modelo en toda la muestra, siguiendo una descomposición aditiva:

    $$ f(x) = \phi_0 + \sum_{j=1}^M \phi_j $$

    donde (phi_j) es el valor SHAP asociado a la variable (j), e indica la contribución de dicha variable al desplazamiento de la predicción con respecto al valor base (phi_0 = {E}[f(X)]), que representa la media de salida del modelo.

    Gráficos interpretativos¶

    • Bar Plot (SHAP Summary): Muestra el impacto medio absoluto $|\phi_j|$ de cada variable sobre la predicción. Variables como ps_car_13, ps_reg_03 o ps_ind_17_bin son las más influyentes, lo que revela su poder discriminativo global.

    • Beeswarm Plot: Complementa lo anterior mostrando la dispersión de los valores SHAP por variable, junto con una codificación de color que representa el valor real de la feature. Este tipo de gráfico permite visualizar interacciones complejas y posibles efectos no lineales.

    Consideraciones matemáticas¶

    • La importancia de una variable está dada por ({E}[|phi_j|]), que cuantifica su efecto medio en la predicción.
    • Los valores SHAP son coherentes con la teoría de juegos (valor de Shapley), garantizando equidad en la atribución de contribuciones incluso en presencia de correlación entre variables.
    • La suma de todos los SHAP de una observación más el valor base devuelve exactamente la probabilidad estimada por el modelo, validando la descomposición.

    Conclusiones¶

    • Variables como ps_car_13 y ps_reg_03 presentan impactos estructurales relevantes y estables en el output del modelo.
    • La variabilidad observada en algunas features sugiere posibles interacciones latentes o efectos de subgrupos, lo cual motiva análisis futuros.
    In [71]:
    # Obtengo una lista de las top n = 5:
    mean_abs_shap = np.abs(shap_values.values).mean(axis=0)
    top_indices = np.argsort(mean_abs_shap)[::-1][:5]
    
    top_vars = X_sample.columns[top_indices].tolist()
    
    print("top_vars = [")
    for var in top_vars:
        print(f"    '{var}',")
    print("]")
    
    top_vars = [
        'ps_car_13',
        'ps_reg_03',
        'ps_ind_17_bin',
        'ps_ind_06_bin',
        'ps_ind_16_bin',
    ]
    
    In [72]:
    # PDP sobre las 5 variables de SHAP global:
    
    columnas_disponibles = [col for col in top_vars if col in X_test_df.columns]
    n_features = len(columnas_disponibles)
    n_cols = 5
    n_rows = math.ceil(n_features / n_cols)
    
    fig, ax = plt.subplots(n_rows, n_cols, figsize=(n_cols * 5, n_rows * 4))
    
    display = PartialDependenceDisplay.from_estimator(
        mejor_modelo,
        X_test_processed,
        features=columnas_disponibles,
        feature_names=X_test_df.columns,
        kind='average',
        ax=ax.ravel().tolist()
    )
    
    fig.suptitle("Partial Dependence Plots - Top SHAP Features", fontsize=18)
    plt.tight_layout(rect=[0, 0, 1, 0.96])
    
    plt.show()
    
    No description has been provided for this image

    Análisis de Partial Dependence Plots (PDP) — Top 5 Features SHAP

    Los Partial Dependence Plots (PDPs) permiten visualizar el efecto marginal de una variable sobre la predicción del modelo, manteniendo constantes todas las demás. Se ha aplicado este análisis a las 5 variables más influyentes según SHAP, sobre una muestra aleatoria del conjunto de test.

    Interpretaciones individuales

    • ps_car_13: presenta una relación positiva clara. A medida que su valor aumenta, también lo hace la probabilidad de siniestro. El efecto es casi monótono, lo que sugiere una correlación directa con el riesgo.
    • ps_reg_03: muestra una pendiente suave y creciente. Su efecto marginal no es tan pronunciado, pero podría tener relevancia combinada con otras variables.
    • ps_ind_17_bin: variable binaria que impacta positivamente en la predicción. Pasar de 0 a 1 implica un aumento en la probabilidad de siniestralidad.
    • ps_ind_06_bin: variable binaria con efecto negativo. Cuando toma el valor 1, reduce la salida del modelo, asociándose con menor riesgo.
    • ps_ind_16_bin: comportamiento similar a la anterior. Su activación reduce la probabilidad predicha, lo que podría interpretarse como un indicador de estabilidad o buen perfil.

    Consideraciones matemáticas

    • Los PDPs complementan a los valores SHAP al mostrar el impacto funcional directo de cada variable.
    • Permiten detectar efectos no lineales y umbrales críticos para ciertas features.
    • Cuando se observan relaciones suaves y estables, se refuerza la robustez del modelo y su interpretabilidad.

    7. Producción (basis)

    Despliegue y operacionalización del modelo¶

    Una vez desarrollado y validado el modelo predictivo de siniestralidad, se debe plantear su puesta en producción bajo un marco que garantice robustez, trazabilidad, interpretabilidad y mantenibilidad. En entornos reales —especialmente aquellos sujetos a regulación como el financiero o asegurador—, no basta con un buen rendimiento en validación cruzada: es necesario garantizar que el modelo sea auditable y reproducible, y que se comporte adecuadamente ante nuevos datos no vistos.

    Para ello, se propone una arquitectura modular basada en microservicios y buenas prácticas de ingeniería de machine learning (MLOps), que separa explícitamente el núcleo predictivo (modelo + preprocesamiento) del API de exposición y del sistema de monitoreo.

    Puesta en producción del modelo

    Aunque el modelo desarrollado no será desplegado en un entorno productivo real, se ha diseñado una arquitectura modular que simula todos los componentes necesarios para su integración en un pipeline industrializado. Este diseño permite demostrar la comprensión del ciclo completo de implementación de modelos de machine learning en producción.

    Estructura general del sistema

    In [ ]:
    ml_scoring_api/
    ├── config.py
    │   # Contiene configuraciones generales del entorno (dev, prod, test), paths de modelos, logging, flags, etc...
    │
    ├── scoring_pipeline.py
    │   # Función principal de predicción, reutiliza todo el preprocesamiento realizado en el entrenamiento:
    │   import [...]
    │   
    │   # Carga de objetos persistidos
    │   modelo_final = joblib.load("model/modelo_XGBoost.pkl")
    │   encoder = joblib.load("model/encoder.pkl")
    │   imputer_cat = joblib.load("model/imputer_cat.pkl")
    │   from variables import categorical_vars, binary_vars, continuous_vars
    │
    │   def predecir(input_df):
    │       [... DETALLE TEÓRICO ABAJO]
    │
    ├── api/
    │   ├── main.py
    │   │   # API REST con FastAPI. Expone un endpoint /predict que recibe JSON y devuelve predicción
    │   │   from fastapi import FastAPI
    │   │   from pydantic import BaseModel
    │   │   import pandas as pd
    │   │   from scoring_pipeline import predecir
    │   │
    │   │   app = FastAPI()
    │   │
    │   │   class InputData(BaseModel):
    │   │       features: dict
    │   │
    │   │   @app.post("/predict")
    │   │   def predict(data: InputData):
    │   │       df = pd.DataFrame([data.features])
    │   │       proba = predecir(df)
    │   │       return {"probabilidad": float(proba[0])}
    │   │
    │   └── schemas.py
    │       # Validación de entrada con Pydantic
    │       from pydantic import BaseModel
    │       from typing import Dict
    │       
    │       class InputSchema(BaseModel):
    │           features: Dict[str, float]
    │
    ├── streamlit_dashboard/
    │   └── app.py
    │       # Interfaz visual con Streamlit que permita la subida de un CSV o introducir valores manuales para pred.
    │       import streamlit as st
    │       import pandas as pd
    │       from scoring_pipeline import predecir
    │
    │       st.title("Scoring de Riesgo de Siniestralidad")
    │       uploaded_file = st.file_uploader("Sube un archivo CSV")
    │
    │       if uploaded_file:
    │           df = pd.read_csv(uploaded_file)
    │           proba = predecir(df)
    │           st.write("Probabilidad de siniestro:", proba)
    │
    ├── model/
    │   ├── modelo_XGBoost.pkl
    │   ├── encoder.pkl
    │   └── imputer_cat.pkl
    │
    ├── variables.py
    │   # Variables usadas en todo el pipeline: categóricas, binarias, continuas
    │   categorical_vars = [...] 
    │   binary_vars = [...]
    │   continuous_vars = [...]
    │
    ├── Dockerfile
    │   # Imagen contenedora del API para correr en producción
    │   # FROM python:3.11
    │   # COPY . /app
    │   # WORKDIR /app
    │   # RUN pip install -r requirements.txt
    │   # CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8080"]
    │
    ├── requirements.txt
    │   # Lista de librerías necesarias para reproducir el entorno
    │   # xgboost, pandas, numpy, fastapi, streamlit, joblib, uvicorn
    │
    └── README.md
        # Instrucciones de despliegue, endpoints disponibles, uso en Streamlit y arquitectura general
    
    In [ ]:
    # scoring_pipeline.py
    def predecir(input_df, modelo_final, encoder, imputer_cat, binary_vars, continuous_vars, categorical_vars):
        
        """
        Aplica el preprocesamiento y devuelve la probabilidad de siniestralidad.
    
        Parameters:
        - input_df: DataFrame de entrada (1 o varios registros).
        - modelo_final: Modelo entrenado (XGBoost).
        - encoder: OneHotEncoder fitted.
        - imputer_cat: Imputador categórico fitted.
        - binary_vars, continuous_vars, categorical_vars: listas de variables según tipo.
    
        Returns:
        - np.array: Probabilidades predichas.
    
        """
    
        # Validación de columnas mínimas necesarias
        columnas_requeridas = binary_vars + continuous_vars + categorical_vars
        missing_cols = [col for col in columnas_requeridas if col not in input_df.columns]
        if missing_cols:
            raise ValueError(f"Faltan columnas en el input: {missing_cols}")
    
        # Imputación y transformación
        X_cat = imputer_cat.transform(input_df[categorical_vars])
        X_cat_encoded = encoder.transform(X_cat)
        X_bin = input_df[binary_vars].values
        X_quant = input_df[continuous_vars].values
    
        # Concatenar todas las variables preprocesadas
        X_processed = np.hstack([X_cat_encoded, X_bin, X_quant])
    
        # Predicción
        probas = modelo_final.predict_proba(X_processed)[:, 1]
        return probas
    

    Descripción de cada componente

    • config.py: archivo centralizado con parámetros de configuración, como rutas, flags de entorno (dev, prod), configuración de logs o hiperparámetros por defecto.
    • scoring_pipeline.py: núcleo del sistema. Implementa el pipeline de inferencia, que carga los objetos entrenados (XGBoost, encoder, imputador) y realiza el mismo preprocesamiento que en fase de entrenamiento. Funciona como punto único para ejecutar predict_proba.
    • variables.py: define las listas de variables categóricas, binarias y continuas usadas en la ingeniería de características, garantizando consistencia entre entrenamiento e inferencia.
    • api/main.py: expone una API RESTful basada en FastAPI. Define un endpoint /predict que acepta datos en formato JSON, los transforma en DataFrame, y devuelve una predicción de probabilidad.
    • api/schemas.py: define los esquemas de validación de entrada y salida usando Pydantic. Garantiza que los datos recibidos por la API tengan el tipo y formato correcto, aumentando robustez.
    • streamlit_dashboard/app.py: interfaz visual construida con Streamlit, orientada a usuarios no técnicos. Permite cargar un archivo CSV o introducir datos manualmente y visualizar en pantalla la probabilidad de siniestro.
    • model/: carpeta con los artefactos persistidos tras el entrenamiento: el modelo final (modelo_XGBoost.pkl), el encoder de variables categóricas y el imputador de missing values.
    • Dockerfile: archivo de contenedor que permite empaquetar todo el sistema para ejecución portable y reproducible. Ideal para entornos cloud o CI/CD. Utiliza Uvicorn como servidor de aplicaciones para FastAPI.
    • requirements.txt: contiene todas las dependencias necesarias del sistema. Incluye librerías como scikit-learn, fastapi, streamlit, joblib, xgboost, etc.
    • README.md: archivo de documentación donde se incluyen instrucciones de instalación, endpoints disponibles, ejemplos de uso y estructura de carpetas. Es el punto de entrada para cualquier usuario que quiera reproducir el sistema.

    Justificación técnica

    El diseño modular permite desacoplar claramente el modelo predictivo de los mecanismos de entrada/salida (API y dashboard). Esta arquitectura permite escalar fácilmente el sistema, mantener trazabilidad sobre las versiones del modelo, aplicar técnicas de calibración post-entrenamiento (como isotonic regression), y facilitar el uso desde distintos frontends (script, API, interfaz gráfica).

    Además, se asegura que los datos pasados al modelo en producción sean transformados de forma idéntica a como fueron tratados en el entrenamiento, evitando fugas de información o errores de formato.

    Relacióncon todo trabajo realizado

    • El modelo de XGBoost se guarda en formato .pkl y se expone mediante un pipeline reproducible, permitiendo predecir nuevos datos de forma consistente.
    • La estructura del dashboard está pensada para facilitar la explicabilidad y la revisión manual por parte del equipo de negocio.
    • La calibración de probabilidades con CalibratedClassifierCV se integra directamente en el pipeline, permitiendo servir probabilidades ajustadas.
    • La API y Streamlit actúan como interfaces de consumo interno (back-office) y externo


    Notebook creado por Marc Román Porras · Última edición: 202505 · GitHub
    In [ ]: